diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58a240dab..3c9507b5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,10 @@ jobs: full_scope=true files="" else - git fetch --quiet --depth=1 origin "$GITHUB_BASE_REF" + # Full base history (no --depth) so the three-dot + # origin/...HEAD diff can resolve a merge base; + # a shallow depth-1 base fetch fails with "no merge base". + git fetch --quiet origin "$GITHUB_BASE_REF" files="$(git diff --name-only "origin/$GITHUB_BASE_REF"...HEAD)" echo "---changed files---" echo "$files" diff --git a/chat-frontend/src/api/searchMessages/index.ts b/chat-frontend/src/api/searchMessages/index.ts index 603b9c062..2437f8589 100644 --- a/chat-frontend/src/api/searchMessages/index.ts +++ b/chat-frontend/src/api/searchMessages/index.ts @@ -8,27 +8,34 @@ export interface SearchMessagesArgs { size: number } +/** Mirrors pkg/model.SearchMessage (the search.messages reply projection). */ export interface SearchMessageHit { messageId: string roomId: string siteId: string - userId: string userAccount: string content: string createdAt: string + editedAt?: string + updatedAt?: string threadParentMessageId?: string threadParentMessageCreatedAt?: string } export interface SearchMessagesResponse { + messages: SearchMessageHit[] total: number - results: SearchMessageHit[] } /** Full-text search across messages. Optionally scope to a room subset. */ export async function searchMessages( { user, request }: Nats, - args: SearchMessagesArgs, + { searchText, roomIds, size }: SearchMessagesArgs, ): Promise { - return request(searchMessagesSubject(user.account), args) + const payload: { query: string; roomIds?: string[]; size: number } = { + query: searchText, + size, + } + if (roomIds) payload.roomIds = roomIds + return request(searchMessagesSubject(user.account), payload) } diff --git a/chat-frontend/src/api/searchRooms/index.ts b/chat-frontend/src/api/searchRooms/index.ts index ac5cf4fc9..aaa5dae2b 100644 --- a/chat-frontend/src/api/searchRooms/index.ts +++ b/chat-frontend/src/api/searchRooms/index.ts @@ -3,33 +3,36 @@ import type { Nats, RoomType } from '../types' export interface SearchRoomsArgs { searchText: string - /** Server-accepted scope values; mirrors search-service's query_rooms.go. - * `'all'` returns rooms anyone can see; the type-specific values narrow. */ - scope: 'all' | 'channel' | 'dm' | 'app' + /** Server-accepted roomType filter; mirrors search-service's + * query_rooms.go. `'all'` (default) returns every accessible room; + * the type-specific values narrow. The server rejects any other value. */ + roomType: 'all' | 'channel' | 'dm' size: number } +/** Mirrors pkg/model.SearchRoom (the search.rooms reply projection). */ export interface SearchRoomHit { roomId: string - roomName: string - roomType: RoomType + name: string + roomType?: RoomType siteId: string - userAccount: string - joinedAt: string } export interface SearchRoomsResponse { - total: number - results: SearchRoomHit[] + rooms: SearchRoomHit[] } /** - * Search rooms the caller is a member of (or all rooms if scope='all'). + * Search rooms the caller is a member of (or all rooms if roomType='all'). * Mirrors search-service's `search.rooms` handler — hits sorted by relevance. */ export async function searchRooms( { user, request }: Nats, - args: SearchRoomsArgs, + { searchText, roomType, size }: SearchRoomsArgs, ): Promise { - return request(searchRoomsSubject(user.account), args) + return request(searchRoomsSubject(user.account), { + query: searchText, + roomType, + size, + }) } diff --git a/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.jsx b/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.jsx index ab4a78380..049efc193 100644 --- a/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.jsx +++ b/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.jsx @@ -12,9 +12,9 @@ export default function SearchBar({ onSelectRoom, onEnterSearch }) { const fetcher = useCallback( async (q) => { - const resp = await searchRooms(nats, { searchText: q, scope: 'all', size: 8 }) + const resp = await searchRooms(nats, { searchText: q, roomType: 'all', size: 8 }) setActiveIdx(0) - return resp.results ?? [] + return resp.rooms ?? [] }, [nats] ) @@ -87,7 +87,7 @@ export default function SearchBar({ onSelectRoom, onEnterSearch }) {
{searchRoomPrefix(hit.roomType)}
-
{hit.roomName}
+
{hit.name}
))}
diff --git a/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.test.jsx b/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.test.jsx index d8c4ede98..944793ec8 100644 --- a/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.test.jsx +++ b/chat-frontend/src/components/MainApp/AppHeader/SearchBar/SearchBar.test.jsx @@ -34,8 +34,8 @@ describe('SearchBar', () => { it('fetches rooms after 250ms debounce when query >= 2 chars', async () => { const request = vi.fn().mockResolvedValue({ - results: [ - { roomId: 'r1', roomName: 'general', roomType: 'c', siteId: 'site-A' }, + rooms: [ + { roomId: 'r1', name: 'general', roomType: 'c', siteId: 'site-A' }, ], total: 1, }) @@ -53,16 +53,16 @@ describe('SearchBar', () => { await waitFor(() => { expect(request).toHaveBeenCalledWith( 'chat.user.alice.request.search.rooms', - { searchText: 'fro', scope: 'all', size: 8 } + { query: 'fro', roomType: 'all', size: 8 } ) }) }) it('shows results in dropdown', async () => { const request = vi.fn().mockResolvedValue({ - results: [ - { roomId: 'r1', roomName: 'frontend-team', roomType: 'c', siteId: 'site-A' }, - { roomId: 'r2', roomName: 'frontend-perf', roomType: 'c', siteId: 'site-A' }, + rooms: [ + { roomId: 'r1', name: 'frontend-team', roomType: 'c', siteId: 'site-A' }, + { roomId: 'r2', name: 'frontend-perf', roomType: 'c', siteId: 'site-A' }, ], total: 2, }) @@ -84,8 +84,8 @@ describe('SearchBar', () => { it('clicking result calls onSelectRoom and clears input', async () => { const onSelectRoom = vi.fn() const request = vi.fn().mockResolvedValue({ - results: [ - { roomId: 'r1', roomName: 'general', roomType: 'c', siteId: 'site-A' }, + rooms: [ + { roomId: 'r1', name: 'general', roomType: 'c', siteId: 'site-A' }, ], total: 1, }) @@ -115,7 +115,7 @@ describe('SearchBar', () => { const onEnterSearch = vi.fn() useNats.mockReturnValue({ user: { account: 'alice' }, - request: vi.fn().mockResolvedValue({ results: [], total: 0 }), + request: vi.fn().mockResolvedValue({ rooms: [], total: 0 }), }) render() @@ -131,7 +131,7 @@ describe('SearchBar', () => { useNats.mockReturnValue({ user: { account: 'alice' }, request: vi.fn().mockResolvedValue({ - results: [{ roomId: 'r1', roomName: 'general', roomType: 'c', siteId: 'site-A' }], + rooms: [{ roomId: 'r1', name: 'general', roomType: 'c', siteId: 'site-A' }], total: 1, }), }) diff --git a/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.jsx b/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.jsx index 45e5c8b43..5837cb2fb 100644 --- a/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.jsx +++ b/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.jsx @@ -29,7 +29,7 @@ export default function InRoomSearch({ roomId, onClose, onJumpToMessage }) { setSubmitted(true) try { const resp = await searchMessages(nats, { searchText: q, roomIds: [roomId], size: 50 }) - setResults(resp.results ?? []) + setResults(resp.messages ?? []) } catch { setResults([]) } finally { diff --git a/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.test.jsx b/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.test.jsx index a99ef15a9..d7b1d4556 100644 --- a/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.test.jsx +++ b/chat-frontend/src/components/MainApp/ChatPage/InRoomSearch/InRoomSearch.test.jsx @@ -14,7 +14,7 @@ describe('InRoomSearch', () => { }) it('does not fetch on typing; fetches on Enter scoped to roomIds: [roomId]', async () => { - const request = vi.fn().mockResolvedValue({ results: [], total: 0 }) + const request = vi.fn().mockResolvedValue({ messages: [], total: 0 }) useNats.mockReturnValue({ user: { account: 'alice' }, request, @@ -38,7 +38,7 @@ describe('InRoomSearch', () => { await waitFor(() => { expect(request).toHaveBeenCalledWith( 'chat.user.alice.request.search.messages', - { searchText: 'hi', roomIds: ['r1'], size: 50 } + { query: 'hi', roomIds: ['r1'], size: 50 } ) }) }) @@ -47,7 +47,7 @@ describe('InRoomSearch', () => { const onJumpToMessage = vi.fn() const onClose = vi.fn() const request = vi.fn().mockResolvedValue({ - results: [ + messages: [ { messageId: 'msg-1', roomId: 'r1', content: 'hello world' }, ], total: 1, @@ -83,7 +83,7 @@ describe('InRoomSearch', () => { const onClose = vi.fn() useNats.mockReturnValue({ user: { account: 'alice' }, - request: vi.fn().mockResolvedValue({ results: [], total: 0 }), + request: vi.fn().mockResolvedValue({ messages: [], total: 0 }), }) render( diff --git a/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.jsx b/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.jsx index b0b24e666..a973423a1 100644 --- a/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.jsx +++ b/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.jsx @@ -23,7 +23,7 @@ const identity = (e) => e const renderChannelResult = (r) => (
- {r.roomName} + {r.name} — {r.siteId}
) @@ -78,8 +78,8 @@ const MemberPicker = forwardRef(function MemberPicker( const channelFetcher = useCallback( async (q) => { - const resp = await searchRooms(nats, { searchText: q, scope: 'all', size: 8 }) - return resp.results ?? [] + const resp = await searchRooms(nats, { searchText: q, roomType: 'all', size: 8 }) + return resp.rooms ?? [] }, [nats] ) diff --git a/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.test.jsx b/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.test.jsx index bda7a55de..8988b22cc 100644 --- a/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.test.jsx +++ b/chat-frontend/src/components/MainApp/ChatPage/ManageMembersDialog/MemberPicker/MemberPicker.test.jsx @@ -10,7 +10,7 @@ vi.mock('@/context/NatsContext', () => ({ import { useNats } from '@/context/NatsContext' function setup(overrides = {}) { - const request = vi.fn().mockResolvedValue({ results: [] }) + const request = vi.fn().mockResolvedValue({ rooms: [] }) useNats.mockReturnValue({ user: { account: 'alice', siteId: 'site-A' }, request, @@ -206,7 +206,7 @@ describe('MemberPicker', () => { it('debounces search.rooms (channels) and adds a ChannelRef when a result is clicked', async () => { const request = vi.fn().mockResolvedValue({ - results: [{ roomId: 'r-x', roomName: 'project-x', siteId: 'site-B', roomType: 'c' }], + rooms: [{ roomId: 'r-x', name: 'project-x', siteId: 'site-B', roomType: 'c' }], }) const onChannelsChange = vi.fn() useNats.mockReturnValue({ user: { account: 'alice', siteId: 'site-A' }, request }) @@ -225,7 +225,7 @@ describe('MemberPicker', () => { await waitFor(() => { expect(request).toHaveBeenCalledWith( 'chat.user.alice.request.search.rooms', - expect.objectContaining({ searchText: 'pro' }) + expect.objectContaining({ query: 'pro' }) ) }) await waitFor(() => expect(screen.getByText('project-x')).toBeInTheDocument()) diff --git a/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.jsx b/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.jsx index 3d4083f36..b3bf01efa 100644 --- a/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.jsx +++ b/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.jsx @@ -31,11 +31,12 @@ export default function SearchResultsPane({ setRoomsError(null) setMsgsError(null) - searchRooms(nats, { searchText: query, scope: 'all', size: 50 }) + searchRooms(nats, { searchText: query, roomType: 'all', size: 50 }) .then((resp) => { if (cancelled) return - setRoomResults(resp.results ?? []) - setRoomTotal(resp.total ?? 0) + const rooms = resp.rooms ?? [] + setRoomResults(rooms) + setRoomTotal(rooms.length) }) .catch((err) => { if (!cancelled) setRoomsError(err?.message || 'Search failed') @@ -47,7 +48,7 @@ export default function SearchResultsPane({ searchMessages(nats, { searchText: query, size: 50 }) .then((resp) => { if (cancelled) return - setMsgResults(resp.results ?? []) + setMsgResults(resp.messages ?? []) setMsgTotal(resp.total ?? 0) }) .catch((err) => { @@ -120,7 +121,7 @@ export default function SearchResultsPane({ {searchRoomPrefix(hit.roomType)} - {hit.roomName} + {hit.name} ))}
diff --git a/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.test.jsx b/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.test.jsx index c2856efaf..cce308163 100644 --- a/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.test.jsx +++ b/chat-frontend/src/components/MainApp/SearchResultsPane/SearchResultsPane.test.jsx @@ -15,10 +15,9 @@ describe('SearchResultsPane', () => { it('fetches and displays room results immediately', async () => { const request = vi.fn().mockResolvedValue({ - results: [ - { roomId: 'r1', roomName: 'general', roomType: 'c', siteId: 'site-A' }, + rooms: [ + { roomId: 'r1', name: 'general', roomType: 'c', siteId: 'site-A' }, ], - total: 1, }) useNats.mockReturnValue({ user: { account: 'alice' }, @@ -40,7 +39,7 @@ describe('SearchResultsPane', () => { expect(request).toHaveBeenCalledWith( 'chat.user.alice.request.search.rooms', - expect.objectContaining({ searchText: 'gen' }) + { query: 'gen', roomType: 'all', size: 50 } ) }) @@ -48,13 +47,12 @@ describe('SearchResultsPane', () => { const request = vi.fn().mockImplementation((subject) => { if (subject.includes('.search.rooms')) { return Promise.resolve({ - results: [{ roomId: 'r1', roomName: 'general', roomType: 'c', siteId: 'site-A' }], - total: 1, + rooms: [{ roomId: 'r1', name: 'general', roomType: 'c', siteId: 'site-A' }], }) } if (subject.includes('.search.messages')) { return Promise.resolve({ - results: [ + messages: [ { messageId: 'm1', roomId: 'r1', content: 'hello', createdAt: '2026-04-17T10:00:00Z', userAccount: 'bob' }, ], total: 1, @@ -93,10 +91,9 @@ describe('SearchResultsPane', () => { const onSelectRoom = vi.fn() const onClose = vi.fn() const request = vi.fn().mockResolvedValue({ - results: [ - { roomId: 'r1', roomName: 'general', roomType: 'c', siteId: 'site-A' }, + rooms: [ + { roomId: 'r1', name: 'general', roomType: 'c', siteId: 'site-A' }, ], - total: 1, }) useNats.mockReturnValue({ user: { account: 'alice' }, @@ -130,11 +127,11 @@ describe('SearchResultsPane', () => { const onClose = vi.fn() const request = vi.fn().mockImplementation((subject) => { if (subject.includes('.search.rooms')) { - return Promise.resolve({ results: [], total: 0 }) + return Promise.resolve({ rooms: [] }) } if (subject.includes('.search.messages')) { return Promise.resolve({ - results: [ + messages: [ { messageId: 'm1', roomId: 'r1', content: 'hello world', createdAt: '2026-04-17T10:00:00Z', userAccount: 'bob' }, ], total: 1, diff --git a/chat-frontend/src/lib/roomFormat.js b/chat-frontend/src/lib/roomFormat.js index db20bbad3..efcb55c8b 100644 --- a/chat-frontend/src/lib/roomFormat.js +++ b/chat-frontend/src/lib/roomFormat.js @@ -31,7 +31,7 @@ export function roomDisplayName(room) { export function roomFromSearchHit(hit) { return { id: hit.roomId, - name: hit.roomName, + name: hit.name, type: hit.roomType, siteId: hit.siteId, } diff --git a/chat-frontend/src/lib/roomFormat.test.js b/chat-frontend/src/lib/roomFormat.test.js index 1fe75c652..1c7b302ec 100644 --- a/chat-frontend/src/lib/roomFormat.test.js +++ b/chat-frontend/src/lib/roomFormat.test.js @@ -78,7 +78,7 @@ describe('roomDisplayName', () => { describe('roomFromSearchHit', () => { it('maps search-hit field names onto the room shape', () => { - const hit = { roomId: 'r1', roomName: 'frontend', roomType: 'channel', siteId: 'site-A' } + const hit = { roomId: 'r1', name: 'frontend', roomType: 'channel', siteId: 'site-A' } expect(roomFromSearchHit(hit)).toEqual({ id: 'r1', name: 'frontend', diff --git a/docker-local/setup.sh b/docker-local/setup.sh index 3fd0e8fba..98ed6478e 100755 --- a/docker-local/setup.sh +++ b/docker-local/setup.sh @@ -68,7 +68,11 @@ docker run --rm \ ' cp "$TMPDIR/backend.creds" "$BACKEND_CREDS" -chmod 600 "$BACKEND_CREDS" +# 0644, not 0600: service containers run as non-root (uid 10001) and +# bind-mount this file read-only at /etc/nats/backend.creds, so the +# in-container user must be able to read it. Acceptable only because +# this is a throwaway local-dev credential generated by this script. +chmod 644 "$BACKEND_CREDS" OPERATOR_JWT=$(cat "$TMPDIR/operator.jwt") ACCOUNT_JWT=$(cat "$TMPDIR/account.jwt") diff --git a/docs/client-api.md b/docs/client-api.md index dae218651..fa17fb056 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -1575,7 +1575,7 @@ See [Error envelope](#6-error-envelope-reference). **Subject:** `chat.user.{account}.request.search.rooms` **Reply subject:** auto-generated `_INBOX.>` (NATS request/reply) -Full-text search across rooms the requester is subscribed to. Results are returned as `SearchRoom` projections hydrated from MongoDB (the caller's per-room subscription documents), not raw ES index fields. +Full-text search across rooms the requester is subscribed to. Results are served directly from the spotlight ES index (one document per `(account, room)` pair), in ES relevance order. ##### Request body @@ -1600,13 +1600,14 @@ Full-text search across rooms the requester is subscribed to. Results are return |---------|-------------------|-------| | `rooms` | array | Page of room results. Empty slice when no matches, never null. | -`SearchRoom` (field list mirrors the legacy HTTP shape — see implementation): +`SearchRoom` (projection of the spotlight ES document): | Field | Type | Notes | |------------|--------|-------| | `roomId` | string | The room's ID. | | `name` | string | The room's display name. | | `roomType` | string | `"channel"`, `"dm"`, or omitted for other types. | +| `siteId` | string | The room's home site. | ```json { @@ -1614,7 +1615,8 @@ Full-text search across rooms the requester is subscribed to. Results are return { "roomId": "01970a4f8c2d7c9aQ", "name": "engineering-announcements", - "roomType": "channel" + "roomType": "channel", + "siteId": "site-a" } ] } @@ -1622,12 +1624,12 @@ Full-text search across rooms the requester is subscribed to. Results are return ##### Error response -See [Error envelope](#5-error-envelope-reference). +See [Error envelope](#6-error-envelope-reference). | Code | Reason | |---------------|--------| | `bad_request` | `query` is missing, empty, or whitespace-only; or `roomType` is `"app"` or an unrecognized value; or `size`/`offset` is negative. | -| `internal` | ES or MongoDB backend failure (transient or permanent). The raw error is never leaked to the client. | +| `internal` | Elasticsearch backend failure (transient or permanent). The raw error is never leaked to the client. | ##### Triggered events — success path diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 7bbb307ec..a77e330d2 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -1769,14 +1769,14 @@ func TestSearchRoomsRequestJSON(t *testing.T) { func TestSearchRoomsResponseJSON(t *testing.T) { resp := model.SearchRoomsResponse{ Rooms: []model.SearchRoom{ - {RoomID: "r1", Name: "engineering-announcements", RoomType: "channel"}, - {RoomID: "r2", Name: "alice-bob", RoomType: "dm"}, + {RoomID: "r1", Name: "engineering-announcements", RoomType: "channel", SiteID: "site-a"}, + {RoomID: "r2", Name: "alice-bob", RoomType: "dm", SiteID: "site-b"}, }, } roundTrip(t, &resp, &model.SearchRoomsResponse{}) } -func TestSearchRoomsResponseJSON_EmptySubscriptions(t *testing.T) { +func TestSearchRoomsResponseJSON_EmptyRooms(t *testing.T) { resp := model.SearchRoomsResponse{Rooms: []model.SearchRoom{}} data, err := json.Marshal(&resp) require.NoError(t, err) diff --git a/pkg/model/search.go b/pkg/model/search.go index ce2126436..2ec321f3c 100644 --- a/pkg/model/search.go +++ b/pkg/model/search.go @@ -61,14 +61,15 @@ type SearchRoomsResponse struct { Rooms []SearchRoom `json:"rooms"` } -// SearchRoom is the per-user-room projection returned by -// search.rooms. Field list mirrors the legacy HTTP shape for -// the /rooms endpoint — fill in additional fields per the legacy -// response during implementation. +// SearchRoom is the per-user-room projection returned by search.rooms, +// built directly from the spotlight ES index hit (one doc per +// (account, room)). SiteID is the room's home site, carried on the +// spotlight doc by search-sync-worker. type SearchRoom struct { - RoomID string `json:"roomId" bson:"roomId"` - Name string `json:"name" bson:"name"` - RoomType string `json:"roomType,omitempty" bson:"roomType,omitempty"` + RoomID string `json:"roomId"` + Name string `json:"name"` + RoomType string `json:"roomType,omitempty"` + SiteID string `json:"siteId"` } // SearchAppsRequest is the NATS payload for `chat.user.{account}.request.search.apps`. diff --git a/pkg/oidc/oidc.go b/pkg/oidc/oidc.go index a32fe2428..18dbf8373 100644 --- a/pkg/oidc/oidc.go +++ b/pkg/oidc/oidc.go @@ -62,7 +62,7 @@ func NewValidator(ctx context.Context, cfg Config) (*Validator, error) { transport := &http.Transport{ TLSClientConfig: &tls.Config{ // #nosec G402 -- InsecureSkipVerify is opt-in via TLSSkipVerify config for dev environments - InsecureSkipVerify: true, //nolint:gosec // intentional for dev environments + InsecureSkipVerify: true, //nolint:gosec MinVersion: tls.VersionTLS12, }, } diff --git a/pkg/searchengine/factory.go b/pkg/searchengine/factory.go index 5ea117dcf..86a4e04bd 100644 --- a/pkg/searchengine/factory.go +++ b/pkg/searchengine/factory.go @@ -41,7 +41,7 @@ func New(ctx context.Context, cfg Config) (SearchEngine, error) { httpTransport := dt.Clone() httpTransport.TLSClientConfig = &tls.Config{ // #nosec G402 -- InsecureSkipVerify is opt-in via TLSSkipVerify config for self-signed ES certs - InsecureSkipVerify: true, //nolint:gosec // intentional: opt-in via config for self-signed ES certs + InsecureSkipVerify: true, //nolint:gosec MinVersion: tls.VersionTLS12, } esCfg.Transport = httpTransport diff --git a/search-service/handler.go b/search-service/handler.go index 7e1239b8f..3be54d0b8 100644 --- a/search-service/handler.go +++ b/search-service/handler.go @@ -159,26 +159,12 @@ func (h *handler) searchRooms(c *natsrouter.Context, req model.SearchRoomsReques return nil, natsrouter.ErrInternal("search backend unavailable") } - roomIDs, err := parseRoomIDs(raw) + rooms, err := parseRooms(raw) if err != nil { - slog.Error("parse subscription room IDs failed", "account", account, "error", err) + slog.Error("parse spotlight rooms failed", "account", account, "error", err) return nil, natsrouter.ErrInternal("unexpected search response") } - - if len(roomIDs) == 0 { - return &model.SearchRoomsResponse{Rooms: []model.SearchRoom{}}, nil - } - - subs, err := h.mongo.HydrateRooms(ctx, account, roomIDs) - if err != nil { - slog.Error("subscription hydration failed", "account", account, "error", err) - return nil, natsrouter.ErrInternal("subscription hydration unavailable") - } - - if subs == nil { - subs = []model.SearchRoom{} - } - return &model.SearchRoomsResponse{Rooms: subs}, nil + return &model.SearchRoomsResponse{Rooms: rooms}, nil } // loadRestricted implements the 2-tier Valkey → ES read. Cache errors diff --git a/search-service/handler_test.go b/search-service/handler_test.go index c716bd0fc..a16c875d7 100644 --- a/search-service/handler_test.go +++ b/search-service/handler_test.go @@ -228,10 +228,6 @@ type fakeMongo struct { searchAppsCalls []searchAppsCall searchAppsResults []model.App searchAppsErr error - - hydrateRoomsCalls []hydrateRoomsCall - hydrateRoomsResults []model.SearchRoom - hydrateRoomsErr error } type searchAppsCall struct { @@ -242,11 +238,6 @@ type searchAppsCall struct { limit int } -type hydrateRoomsCall struct { - account string - roomIDs []string -} - func (f *fakeMongo) SearchAppsByName( _ context.Context, query, account string, @@ -262,46 +253,24 @@ func (f *fakeMongo) SearchAppsByName( return f.searchAppsResults, nil } -func (f *fakeMongo) HydrateRooms( - _ context.Context, - account string, - roomIDs []string, -) ([]model.SearchRoom, error) { - f.hydrateRoomsCalls = append(f.hydrateRoomsCalls, hydrateRoomsCall{ - account: account, roomIDs: roomIDs, - }) - if f.hydrateRoomsErr != nil { - return nil, f.hydrateRoomsErr - } - return f.hydrateRoomsResults, nil -} - func TestHandler_SearchRooms_HappyPath(t *testing.T) { store := &fakeStore{ - searchBody: json.RawMessage(`{"hits":{"total":{"value":2},"hits":[{"_source":{"roomId":"r1"}},{"_source":{"roomId":"r2"}}]}}`), - } - mongo := &fakeMongo{ - hydrateRoomsResults: []model.SearchRoom{ - {RoomID: "r1", Name: "general", RoomType: "channel"}, - {RoomID: "r2", Name: "alice-bob", RoomType: "dm"}, - }, + searchBody: json.RawMessage(`{"hits":{"total":{"value":2},"hits":[` + + `{"_source":{"roomId":"r1","roomName":"general","roomType":"channel","siteId":"site-a"}},` + + `{"_source":{"roomId":"r2","roomName":"alice-bob","roomType":"dm","siteId":"site-b"}}]}}`), } - h := newTestHandler(store, mongo, nil, newFakeCache()) + h := newTestHandler(store, &fakeMongo{}, nil, newFakeCache()) resp, err := h.searchRooms(ctxWithAccount("alice"), model.SearchRoomsRequest{Query: "general"}) require.NoError(t, err) require.NotNil(t, resp) require.Len(t, resp.Rooms, 2) - assert.Equal(t, "r1", resp.Rooms[0].RoomID) - assert.Equal(t, "general", resp.Rooms[0].Name) + assert.Equal(t, model.SearchRoom{RoomID: "r1", Name: "general", RoomType: "channel", SiteID: "site-a"}, resp.Rooms[0]) + assert.Equal(t, "r2", resp.Rooms[1].RoomID) + assert.Equal(t, "site-b", resp.Rooms[1].SiteID) require.Len(t, store.searchCalls, 1) assert.Equal(t, []string{testSpotlightIndex}, store.searchCalls[0].indices) - - require.Len(t, mongo.hydrateRoomsCalls, 1) - call := mongo.hydrateRoomsCalls[0] - assert.Equal(t, "alice", call.account) - assert.Equal(t, []string{"r1", "r2"}, call.roomIDs) } func TestHandler_SearchRooms_EmptyQueryRejected(t *testing.T) { @@ -352,33 +321,17 @@ func TestHandler_SearchRooms_ESErrorSanitized(t *testing.T) { assert.NotContains(t, rerr.Message, "es failed") } -func TestHandler_SearchRooms_MongoErrorSanitized(t *testing.T) { - store := &fakeStore{ - searchBody: json.RawMessage(`{"hits":{"total":{"value":1},"hits":[{"_source":{"roomId":"r1"}}]}}`), - } - mongo := &fakeMongo{hydrateRoomsErr: errors.New("mongo down")} - h := newTestHandler(store, mongo, nil, newFakeCache()) - _, err := h.searchRooms(ctxWithAccount("alice"), model.SearchRoomsRequest{Query: "general"}) - require.Error(t, err) - var rerr *natsrouter.RouteError - require.True(t, errors.As(err, &rerr)) - assert.Equal(t, natsrouter.CodeInternal, rerr.Code) - assert.NotContains(t, rerr.Message, "mongo down") -} - -func TestHandler_SearchRooms_EmptyESResultSkipsMongo(t *testing.T) { +func TestHandler_SearchRooms_EmptyESResult(t *testing.T) { store := &fakeStore{ searchBody: json.RawMessage(`{"hits":{"total":{"value":0},"hits":[]}}`), } - mongo := &fakeMongo{} - h := newTestHandler(store, mongo, nil, newFakeCache()) + h := newTestHandler(store, &fakeMongo{}, nil, newFakeCache()) resp, err := h.searchRooms(ctxWithAccount("alice"), model.SearchRoomsRequest{Query: "nope"}) require.NoError(t, err) require.NotNil(t, resp) assert.NotNil(t, resp.Rooms, "must be empty slice, not nil") assert.Empty(t, resp.Rooms) - assert.Len(t, mongo.hydrateRoomsCalls, 0, "no Mongo call when ES returns no hits") } func TestHandler_SearchRooms_SizeClamped(t *testing.T) { diff --git a/search-service/integration_test.go b/search-service/integration_test.go index b3b55462c..14dcd5780 100644 --- a/search-service/integration_test.go +++ b/search-service/integration_test.go @@ -889,17 +889,16 @@ func TestIntegration_SearchUsers_ThirdPartyErrorReturnsInternal(t *testing.T) { // --- search.rooms integration ---------------------------------------- -// roomsFixture wires a real Mongo container alongside a real ES container -// (for the spotlight index) and NATS. ES is needed to seed spotlight docs; -// Mongo is needed to seed subscription docs for hydration. +// roomsFixture wires a real ES container (for the spotlight index) and +// NATS. search.rooms is served directly from the spotlight index, so no +// Mongo is involved. type roomsFixture struct { clientNATS *nats.Conn - mongoDB *mongo.Database esURL string } -// setupRoomsFixture stands up ES (spotlight index), Mongo (subscriptions), and -// NATS. It registers t.Cleanup for all containers and returns a ready fixture. +// setupRoomsFixture stands up ES (spotlight index) and NATS. It registers +// t.Cleanup for all containers and returns a ready fixture. func setupRoomsFixture(t *testing.T) *roomsFixture { t.Helper() ctx := context.Background() @@ -936,8 +935,6 @@ func setupRoomsFixture(t *testing.T) *roomsFixture { spotlightIndex := "spotlight-subs-test" putTestSpotlightIndex(t, esURL, spotlightIndex) - mongoDB := testutil.MongoDB(t, "search_service_test") - natsURL := startNATS(t) serverNC, err := natsutil.Connect(natsURL, "") require.NoError(t, err, "connect nats (server side)") @@ -951,9 +948,8 @@ func setupRoomsFixture(t *testing.T) *roomsFixture { require.NoError(t, err, "build searchengine for subs fixture") esStore := newESStore(engine, testUserRoomIndex) - mStore := newMongoStore(mongoDB) cache := newValkeyCache(newSubsValkeyClient(t)) - h := newHandler(esStore, mStore, nil, cache, handlerConfig{ + h := newHandler(esStore, nil, nil, cache, handlerConfig{ DocCounts: 25, MaxDocCounts: 100, RestrictedRoomsCacheTTL: 5 * time.Minute, @@ -969,7 +965,7 @@ func setupRoomsFixture(t *testing.T) *roomsFixture { require.NoError(t, serverNC.NatsConn().Flush()) t.Cleanup(func() { _ = router.Shutdown(context.Background()) }) - return &roomsFixture{clientNATS: clientNC, mongoDB: mongoDB, esURL: esURL} + return &roomsFixture{clientNATS: clientNC, esURL: esURL} } // newSubsValkeyClient starts a Valkey testcontainer and returns a connected @@ -1021,7 +1017,6 @@ func putTestSpotlightIndex(t *testing.T, esURL, index string) { func TestIntegration_SearchRooms_HappyPath(t *testing.T) { f := setupRoomsFixture(t) - ctx := context.Background() const account = "alice" now := time.Now().UTC() @@ -1043,25 +1038,17 @@ func TestIntegration_SearchRooms_HappyPath(t *testing.T) { "siteId": "site-local", "joinedAt": now.Add(-24 * time.Hour).Format(time.RFC3339), }) - - // Seed subscription docs for those rooms. - _, err := f.mongoDB.Collection("subscriptions").InsertMany(ctx, []any{ - map[string]any{ - "_id": "sub-r1", - "roomId": "r1", - "name": "engineering-announcements", - "roomType": "channel", - "u": map[string]any{"account": account}, - }, - map[string]any{ - "_id": "sub-r2", - "roomId": "r2", - "name": "engineering-random", - "roomType": "channel", - "u": map[string]any{"account": account}, - }, + // A matching room owned by a different account. With the Mongo + // hydration removed, the spotlight userAccount term filter is the + // sole access boundary — this must not leak into alice's results. + seedDoc(t, f.esURL, "spotlight-subs-test", "spot-r3", map[string]any{ + "roomId": "r3", + "roomName": "engineering-secret", + "roomType": "channel", + "userAccount": "mallory", + "siteId": "site-local", + "joinedAt": now.Add(-12 * time.Hour).Format(time.RFC3339), }) - require.NoError(t, err) reqBytes, err := json.Marshal(model.SearchRoomsRequest{Query: "engineering"}) require.NoError(t, err) @@ -1072,15 +1059,19 @@ func TestIntegration_SearchRooms_HappyPath(t *testing.T) { var resp model.SearchRoomsResponse require.NoError(t, json.Unmarshal(msg.Data, &resp)) - assert.Len(t, resp.Rooms, 2, "both rooms matching 'engineering' must be returned") - roomIDs := []string{resp.Rooms[0].RoomID, resp.Rooms[1].RoomID} - assert.Contains(t, roomIDs, "r1") - assert.Contains(t, roomIDs, "r2") + require.Len(t, resp.Rooms, 2, "both rooms matching 'engineering' must be returned") + byID := map[string]model.SearchRoom{} + for _, r := range resp.Rooms { + byID[r.RoomID] = r + } + assert.Equal(t, model.SearchRoom{RoomID: "r1", Name: "engineering-announcements", RoomType: "channel", SiteID: "site-local"}, byID["r1"]) + assert.Equal(t, model.SearchRoom{RoomID: "r2", Name: "engineering-random", RoomType: "channel", SiteID: "site-local"}, byID["r2"]) + _, leaked := byID["r3"] + assert.False(t, leaked, "rooms owned by another account must not leak") } func TestIntegration_SearchRooms_RoomTypeChannelFilter(t *testing.T) { f := setupRoomsFixture(t) - ctx := context.Background() const account = "bob" now := time.Now().UTC() @@ -1102,12 +1093,6 @@ func TestIntegration_SearchRooms_RoomTypeChannelFilter(t *testing.T) { "joinedAt": now.Add(-2 * time.Hour).Format(time.RFC3339), }) - _, err := f.mongoDB.Collection("subscriptions").InsertMany(ctx, []any{ - map[string]any{"_id": "sub-b-r1", "roomId": "b-r1", "name": "bob-alice", "roomType": "dm", "u": map[string]any{"account": account}}, - map[string]any{"_id": "sub-b-r2", "roomId": "b-r2", "name": "bob-channel", "roomType": "channel", "u": map[string]any{"account": account}}, - }) - require.NoError(t, err) - reqBytes, err := json.Marshal(model.SearchRoomsRequest{Query: "bob", RoomType: "channel"}) require.NoError(t, err) @@ -1118,7 +1103,8 @@ func TestIntegration_SearchRooms_RoomTypeChannelFilter(t *testing.T) { require.NoError(t, json.Unmarshal(msg.Data, &resp)) require.Len(t, resp.Rooms, 1) - assert.Equal(t, "b-r2", resp.Rooms[0].RoomID, "only the channel room must match roomType=channel filter") + assert.Equal(t, model.SearchRoom{RoomID: "b-r2", Name: "bob-channel", RoomType: "channel", SiteID: "site-local"}, resp.Rooms[0], + "only the channel room must match roomType=channel filter") } func TestIntegration_SearchRooms_EmptyQueryReturnsBadRequest(t *testing.T) { diff --git a/search-service/mock_store_test.go b/search-service/mock_store_test.go index 5a125b5ed..f6d8a87ea 100644 --- a/search-service/mock_store_test.go +++ b/search-service/mock_store_test.go @@ -152,21 +152,6 @@ func (m *MockMongoStore) EXPECT() *MockMongoStoreMockRecorder { return m.recorder } -// HydrateRooms mocks base method. -func (m *MockMongoStore) HydrateRooms(ctx context.Context, account string, roomIDs []string) ([]model.SearchRoom, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HydrateRooms", ctx, account, roomIDs) - ret0, _ := ret[0].([]model.SearchRoom) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// HydrateRooms indicates an expected call of HydrateRooms. -func (mr *MockMongoStoreMockRecorder) HydrateRooms(ctx, account, roomIDs any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HydrateRooms", reflect.TypeOf((*MockMongoStore)(nil).HydrateRooms), ctx, account, roomIDs) -} - // SearchAppsByName mocks base method. func (m *MockMongoStore) SearchAppsByName(ctx context.Context, query, account string, assistantEnabled *bool, offset, limit int) ([]model.App, error) { m.ctrl.T.Helper() diff --git a/search-service/response.go b/search-service/response.go index 01c31526d..bb6ccc93c 100644 --- a/search-service/response.go +++ b/search-service/response.go @@ -40,11 +40,23 @@ type messageSearchHit struct { ThreadParentCreatedAt *time.Time `json:"threadParentMessageCreatedAt,omitempty"` } -// roomSearchHit is the ES `_source` shape for a spotlight hit used -// during the subscription search flow. Only `roomId` is extracted; the other -// fields are present in the index but unused after the Mongo hydration step. +// roomSearchHit is the spotlight ES `_source` shape for a room +// typeahead hit. search.rooms is served directly from this index +// (one doc per (account, room)); the fields map onto model.SearchRoom. type roomSearchHit struct { - RoomID string `json:"roomId"` + RoomID string `json:"roomId"` + RoomName string `json:"roomName"` + RoomType string `json:"roomType"` + SiteID string `json:"siteId"` +} + +func toSearchRoom(h roomSearchHit) model.SearchRoom { + return model.SearchRoom{ + RoomID: h.RoomID, + Name: h.RoomName, + RoomType: h.RoomType, + SiteID: h.SiteID, + } } func parseMessagesResponse(raw json.RawMessage) ([]messageSearchHit, int64, error) { @@ -79,18 +91,17 @@ func toSearchMessage(hit *messageSearchHit) model.SearchMessage { } } -// parseRoomIDs extracts the ordered list of room IDs from a -// spotlight ES response. The caller passes these IDs to HydrateRooms -// for Mongo enrichment. -func parseRoomIDs(raw json.RawMessage) ([]string, error) { +// parseRooms extracts the ordered list of rooms from a spotlight ES +// response, preserving ES relevance order. +func parseRooms(raw json.RawMessage) ([]model.SearchRoom, error) { var rr rawResponse[roomSearchHit] if err := json.Unmarshal(raw, &rr); err != nil { - return nil, fmt.Errorf("parse subscription room IDs: %w", err) + return nil, fmt.Errorf("parse spotlight rooms response: %w", err) } - ids := make([]string, 0, len(rr.Hits.Hits)) + rooms := make([]model.SearchRoom, 0, len(rr.Hits.Hits)) for i := range rr.Hits.Hits { - ids = append(ids, rr.Hits.Hits[i].Source.RoomID) + rooms = append(rooms, toSearchRoom(rr.Hits.Hits[i].Source)) } - return ids, nil + return rooms, nil } diff --git a/search-service/response_test.go b/search-service/response_test.go index 726d15734..bb276f4de 100644 --- a/search-service/response_test.go +++ b/search-service/response_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/hmchangw/chat/pkg/model" ) func TestParseMessagesResponse_HappyPath(t *testing.T) { @@ -126,56 +128,58 @@ func TestToSearchMessage_ThreadParentBothFieldsCopied(t *testing.T) { assert.True(t, got.ThreadParentMessageCreatedAt.Equal(tp)) } -func TestParseSubscriptionRoomIDs_HappyPath(t *testing.T) { +func TestParseRooms_HappyPath(t *testing.T) { body := json.RawMessage(`{ "hits": { "total": {"value": 2}, "hits": [ {"_source": { - "roomId": "r1", "roomName": "general", "roomType": "p", + "roomId": "r1", "roomName": "general", "roomType": "channel", "userAccount": "alice", "siteId": "site-a", "joinedAt": "2026-04-01T12:00:00Z" }}, {"_source": { - "roomId": "r2", "roomName": "alice-bob", "roomType": "d", - "userAccount": "alice", "siteId": "site-a", + "roomId": "r2", "roomName": "alice-bob", "roomType": "dm", + "userAccount": "alice", "siteId": "site-b", "joinedAt": "2026-04-02T12:00:00Z" }} ] } }`) - ids, err := parseRoomIDs(body) + rooms, err := parseRooms(body) require.NoError(t, err) - require.Len(t, ids, 2) - assert.Equal(t, "r1", ids[0]) - assert.Equal(t, "r2", ids[1]) + require.Len(t, rooms, 2) + assert.Equal(t, model.SearchRoom{RoomID: "r1", Name: "general", RoomType: "channel", SiteID: "site-a"}, rooms[0]) + assert.Equal(t, model.SearchRoom{RoomID: "r2", Name: "alice-bob", RoomType: "dm", SiteID: "site-b"}, rooms[1]) } -func TestParseSubscriptionRoomIDs_Empty(t *testing.T) { +func TestParseRooms_Empty(t *testing.T) { body := json.RawMessage(`{"hits":{"total":{"value":0},"hits":[]}}`) - ids, err := parseRoomIDs(body) + rooms, err := parseRooms(body) require.NoError(t, err) - assert.Empty(t, ids) + assert.Empty(t, rooms) + assert.NotNil(t, rooms, "must be empty slice, not nil") } -func TestParseSubscriptionRoomIDs_Malformed(t *testing.T) { - _, err := parseRoomIDs(json.RawMessage(`{`)) +func TestParseRooms_Malformed(t *testing.T) { + _, err := parseRooms(json.RawMessage(`{`)) assert.Error(t, err) } -func TestParseSubscriptionRoomIDs_PreservesOrder(t *testing.T) { +func TestParseRooms_PreservesOrder(t *testing.T) { body := json.RawMessage(`{ "hits": { "total": {"value": 3}, "hits": [ - {"_source": {"roomId": "r3"}}, - {"_source": {"roomId": "r1"}}, - {"_source": {"roomId": "r2"}} + {"_source": {"roomId": "r3", "roomName": "c"}}, + {"_source": {"roomId": "r1", "roomName": "a"}}, + {"_source": {"roomId": "r2", "roomName": "b"}} ] } }`) - ids, err := parseRoomIDs(body) + rooms, err := parseRooms(body) require.NoError(t, err) - assert.Equal(t, []string{"r3", "r1", "r2"}, ids, "ES hit order must be preserved for Mongo hydration") + got := []string{rooms[0].RoomID, rooms[1].RoomID, rooms[2].RoomID} + assert.Equal(t, []string{"r3", "r1", "r2"}, got, "ES relevance order must be preserved") } diff --git a/search-service/store.go b/search-service/store.go index 2c9f27f02..f3e68c5bb 100644 --- a/search-service/store.go +++ b/search-service/store.go @@ -40,17 +40,6 @@ type MongoStore interface { assistantEnabled *bool, offset, limit int, ) ([]model.App, error) - - // HydrateRooms fetches the caller's Subscription documents for - // the given room IDs and returns them as SearchRoom projections. - // The returned slice preserves the ordering of roomIDs. Room IDs for - // which no subscription exists in Mongo are silently omitted (the user - // may have left the room between the ES query and the Mongo fetch). - HydrateRooms( - ctx context.Context, - account string, - roomIDs []string, - ) ([]model.SearchRoom, error) } // SearchUsersClient is the outbound HTTP interface for user search. diff --git a/search-service/store_mongo.go b/search-service/store_mongo.go index 8277de504..e60a93832 100644 --- a/search-service/store_mongo.go +++ b/search-service/store_mongo.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "github.com/hmchangw/chat/pkg/model" @@ -12,14 +11,12 @@ import ( // mongoStore is the Mongo-backed implementation of MongoStore. type mongoStore struct { - apps *mongo.Collection - subscriptions *mongo.Collection + apps *mongo.Collection } func newMongoStore(db *mongo.Database) *mongoStore { return &mongoStore{ - apps: db.Collection("apps"), - subscriptions: db.Collection("subscriptions"), + apps: db.Collection("apps"), } } @@ -42,52 +39,3 @@ func (s *mongoStore) SearchAppsByName( } return results, nil } - -// HydrateRooms fetches the caller's subscription documents for the -// given room IDs from the `subscriptions` collection and projects them into -// []model.SearchRoom. Documents are matched by `u.account` (caller's -// account) and `roomId` (one of the provided IDs). Missing subscriptions are -// silently omitted. The bson projection maps directly onto model.SearchRoom -// via the `bson:"..."` tags on that struct. -func (s *mongoStore) HydrateRooms( - ctx context.Context, - account string, - roomIDs []string, -) ([]model.SearchRoom, error) { - if len(roomIDs) == 0 { - return []model.SearchRoom{}, nil - } - - filter := bson.D{ - {Key: "u.account", Value: account}, - {Key: "roomId", Value: bson.D{{Key: "$in", Value: roomIDs}}}, - } - - cur, err := s.subscriptions.Find(ctx, filter) - if err != nil { - return nil, fmt.Errorf("find subscriptions: %w", err) - } - defer cur.Close(ctx) - - results := make([]model.SearchRoom, 0) - if err := cur.All(ctx, &results); err != nil { - return nil, fmt.Errorf("decode subscriptions: %w", err) - } - - // Reorder results to match the input roomIDs order (Mongo $in does - // not preserve input order; the caller depends on ES relevance ranking - // surviving the hydration step). Subscriptions missing from Mongo - // (e.g. the user left between the ES query and this lookup) are - // silently omitted. - byRoomID := make(map[string]model.SearchRoom, len(results)) - for _, sub := range results { - byRoomID[sub.RoomID] = sub - } - ordered := make([]model.SearchRoom, 0, len(results)) - for _, roomID := range roomIDs { - if sub, ok := byRoomID[roomID]; ok { - ordered = append(ordered, sub) - } - } - return ordered, nil -}