diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index 9741b3e..8b490fc 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -273,10 +273,23 @@ beforeAll(async () => { SubRooms: [], } as Record) } - // Non-public and a dorm: neither belongs in a discovery row. + // Non-public, a dorm, and a public room OPTED OUT of lists: none belongs in a discovery + // row. Room 9 is built to lead every row it is wrongly let into — newest of all, tagged + // `quest`, and (in the hot test) occupied — so a feed that forgets `ExcludeFromLists` + // fails loudly rather than by luck of ordering. for (const room of [ { RoomId: 5, Name: 'SecretRoom', CreatorAccountId: 500, Accessibility: 0, IsDorm: false }, { RoomId: 6, Name: '@Dorm', CreatorAccountId: 502, Accessibility: 1, IsDorm: true }, + { + RoomId: 9, + Name: 'OptedOut', + CreatorAccountId: 505, + Accessibility: 1, + IsDorm: false, + ExcludeFromLists: true, + CreatedAt: '2026-04-01T00:00:00Z', + Tags: [{ Tag: 'quest', Type: 0 }], + }, ]) { await seedRoomWithSubRooms(env.DB, { ...room, SubRooms: [] } as Record) } @@ -1201,6 +1214,9 @@ it('serves the live hot-room ranking for /algorithmiclists/HotList', async () => await putInRoom(904, 2) await putInRoom(905, 2) await putInRoom(906, 2) + // Room 9 is as busy as the leader and opted out of lists: it must not place. + await putInRoom(907, 9) + await putInRoom(908, 9) const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/HotList?type=1`) expect(res.status).toBe(200) @@ -1220,9 +1236,11 @@ it('serves the live hot-room ranking for /algorithmiclists/HotList', async () => // the Coach, whose stock rooms the hot row leaves out — a "Hot" row full of Rec Center // is a row about the server rather than about what players are doing. expect(ids).not.toContain('2') - // The private room and the dorm are not in it either. + // The private room and the dorm are not in it either, nor the public room whose + // `ExcludeFromLists` is set — busy as it is. expect(ids).not.toContain('5') expect(ids).not.toContain('6') + expect(ids).not.toContain('9') // The row key is matched case-insensitively — it reaches us from a curated page's // ItemIds, whose casing is the reference's. @@ -1264,7 +1282,7 @@ it('orders /algorithmiclists/new by creation time', async () => { }) it.each(['recentlyupdated', 'new'])( - 'leaves stock, private and dorm rooms out of %s', + 'leaves stock, private, dorm and list-excluded rooms out of %s', async (list) => { const ids = await rowIds(list) // Room 2 is the Coach's — this server's stock rooms, which a row about what players have @@ -1273,6 +1291,9 @@ it.each(['recentlyupdated', 'new'])( // Not public, and a dorm. expect(ids).not.toContain('5') expect(ids).not.toContain('6') + // Public and player-made, but `ExcludeFromLists` — and the newest room seeded, so it + // would head `new` if the flag were ignored. + expect(ids).not.toContain('9') } ) @@ -1338,6 +1359,8 @@ it('answers recentlyvisited empty for a caller with no history and no token', as it('serves the rooms tagged `quest` for /algorithmiclists/quests_algoendpoint', async () => { const ids = await rowIds('quests_algoendpoint') // Ordering is the hot feed's (live players, then engagement), so assert membership. + // Room 9 carries the tag too and is absent: a category row asks what a room is about, + // but `ExcludeFromLists` still keeps it out of every row. expect([...ids].sort()).toEqual(['2', '3']) // Room 2 is the COACH's and belongs here all the same. Every quest-tagged room this diff --git a/apps/rooms/migrations/0027_room_searchable.sql b/apps/rooms/migrations/0027_room_searchable.sql new file mode 100644 index 0000000..a6472dc --- /dev/null +++ b/apps/rooms/migrations/0027_room_searchable.sql @@ -0,0 +1,16 @@ +-- The flag SEARCH filters on, as an indexed column — the search-side twin of +-- `exclude_from_lists` (0014). +-- +-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync. +-- +-- `ExcludeFromLists` keeps a room out of the browse and discovery feeds; `ExcludeFromSearch` +-- keeps it out of `/rooms/search` and the search box's suggestions. They are independent: +-- a room may opt out of the feeds and still be findable by name, or the other way round. +-- Until now nothing read `ExcludeFromSearch` at all, so an opted-out room was served by +-- every search that matched it. +-- +-- VIRTUAL like `exclude_from_lists`, so the blob stays the only copy and no backfill is +-- needed. It is not added to `idx_room_public`: that partial index already narrows a search +-- to the public, non-dorm rooms, and this term is then checked against the handful of rows +-- it fetched — see SEARCHABLE_WHERE. +ALTER TABLE room ADD COLUMN exclude_from_search INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromSearch')) VIRTUAL; diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 3a80a79..f6a1315 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1179,8 +1179,9 @@ const app = new Hono() ) // Room search: `query` is space/`+`-separated terms — `#tag` matches room tags, - // plain terms match the name. Public, non-dorm rooms only. Paginated via - // skip/take. Returns `{ Results, TotalResults }`. + // plain terms match the name. Public, non-dorm rooms not opted out of search + // (`ExcludeFromSearch`) only — `ExcludeFromLists` is the feeds' flag and does not hide + // a room here. Paginated via skip/take. Returns `{ Results, TotalResults }`. // // `#community` is the one tag term that isn't a tag lookup: it is the browse chip's // pseudo-tag reaching search, and means rooms a PLAYER made rather than the seeded @@ -1192,7 +1193,9 @@ const app = new Hono() summary: 'Search rooms', description: [ 'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the', - 'room’s tags, a plain term matches its name. Public, non-dorm rooms only.', + 'room’s tags, a plain term matches its name. Public, non-dorm rooms only, and never one', + 'whose `ExcludeFromSearch` is set; `ExcludeFromLists` hides a room from the feeds, not', + 'from here.', '`#community` is a pseudo-tag no room carries — it narrows to rooms a player made', '(anything the system Coach account didn’t create), like `/rooms/hot?tag=community`.', ].join(' '), @@ -1218,8 +1221,9 @@ const app = new Hono() // // Every suggestion is something `/rooms/search` will actually find, so submitting one // can't come back empty: they're drawn from room names (what a plain search term - // matches) and room tags (what a `#tag` term matches), over the same public, non-dorm - // rooms search considers. Tags come back with their `#` for that reason. + // matches) and room tags (what a `#tag` term matches), over the same public, non-dorm, + // searchable rooms search considers — a room opted out of search is not suggested + // either. Tags come back with their `#` for that reason. // // `searchSessionId` is the client's own correlation id for a typing session — it ties // the keystrokes and the eventual search together in the reference's analytics. Nothing @@ -1231,8 +1235,9 @@ const app = new Hono() summary: 'Search suggestions for the search box', description: [ 'Type-ahead suggestions as a bare array of strings — not rooms, not an envelope.', - 'Drawn from room names and room tags over the public, non-dorm rooms `/rooms/search`', - 'searches, so every suggestion is one that finds something when submitted; a tag', + 'Drawn from room names and room tags over the public, non-dorm, searchable rooms', + '`/rooms/search` searches, so every suggestion is one that finds something when', + 'submitted and a room opted out of search is never suggested; a tag', 'suggestion carries its `#` so it searches by tag. A `query` starting with `#`', 'suggests tags only. Matches that START with the query come first, then ones that', 'merely contain it, alphabetically within each — the same query always suggests the', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index d0d8bf6..039880d 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1204,7 +1204,7 @@ describe('rooms endpoints', () => { IsDorm: false, SubRooms: [], }) - // Same room, opted out. + // Same room, opted out of the feeds. await seed({ RoomId: 30802, Name: 'OptedOut', @@ -1214,6 +1214,16 @@ describe('rooms endpoints', () => { ExcludeFromLists: true, SubRooms: [], }) + // And one opted out of SEARCH instead — the other flag, which the feeds ignore. + await seed({ + RoomId: 30803, + Name: 'Unsearchable', + CreatorAccountId: 830, + Accessibility: 1, + IsDorm: false, + ExcludeFromSearch: true, + SubRooms: [], + }) const namesIn = async (path: string) => { const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as @@ -1224,13 +1234,22 @@ describe('rooms endpoints', () => { for (const feed of ['/rooms/hot?take=200', '/rooms/recommendations?take=200']) { expect(await namesIn(feed)).toContain('NoExcludeKey') expect(await namesIn(feed)).not.toContain('OptedOut') + // `ExcludeFromSearch` is search's flag, not the feeds': the room still lists. + expect(await namesIn(feed)).toContain('Unsearchable') } - // SEARCH is the wider filter (PUBLIC_WHERE): a room can opt out of the browse feeds - // and still be findable by name, so this must not have narrowed with the feeds. + // SEARCH is its own filter (SEARCHABLE_WHERE): a room can opt out of the browse feeds + // and still be findable by name, so this must not have narrowed with the feeds… expect(await namesIn('/rooms/search?query=optedout')).toContain('OptedOut') + // …while a room opted out of SEARCH is not found, by name or by the missing key's + // opposite — and the suggestion box, which draws from the same rooms, never offers it. + expect(await namesIn('/rooms/search?query=unsearchable')).toEqual([]) + expect(await namesIn('/rooms/search?query=noexcludekey')).toContain('NoExcludeKey') + expect( + await (await SELF.fetch(`${ORIGIN}/rooms/autocomplete_search?query=unsearch&take=10`)).json() + ).toEqual([]) - await env.DB.prepare('DELETE FROM room WHERE room_id IN (30801, 30802)').run() + await env.DB.prepare('DELETE FROM room WHERE room_id IN (30801, 30802, 30803)').run() }) // Served only to the client builds that render it — the 2023 client's other room diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index e57080f..1fb13b1 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -45,7 +45,10 @@ export const ROOM_SCHEMA_DDL: string[] = [ -- rest so the blob stays the only copy; they exist to be INDEXED — see -- {@link LISTABLE_WHERE}. accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL, - exclude_from_lists INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromLists')) VIRTUAL + exclude_from_lists INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromLists')) VIRTUAL, + -- The flag SEARCH filters on (migrations/0027_room_searchable.sql) — see + -- {@link SEARCHABLE_WHERE}. + exclude_from_search INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromSearch')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`, `CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`, @@ -2069,11 +2072,23 @@ const LISTABLE_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_ /** * The wider half of {@link LISTABLE_WHERE}: public and not a dorm, without the - * `ExcludeFromLists` term. What SEARCH considers — a room can opt out of the browse feeds - * and still be findable by name — so the two searching reads select on this instead. + * `ExcludeFromLists` term. What `idx_room_public` covers, and the base of + * {@link SEARCHABLE_WHERE}. */ const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1' +/** + * What SEARCH considers — the same test {@link isSearchable} makes in memory, pushed down + * like {@link LISTABLE_WHERE} is for the feeds. `ExcludeFromSearch` is a room's own flag, + * independent of `ExcludeFromLists`: a room can opt out of the browse feeds and still be + * findable by name, or be in every feed and unfindable by name. So the two searching reads + * ({@link searchRooms}, {@link autocompleteRoomSearch}) select on this and NOT on the + * listable predicate; `idx_room_public` still narrows them to the public rooms, and the + * search term is checked against the rows it fetched. The in-memory filter stays, for the + * same reason it does with the feeds. + */ +const SEARCHABLE_WHERE = `${PUBLIC_WHERE} AND exclude_from_search IS NOT 1` + /** * The columns a scan-then-rank feed selects in place of {@link ROOM_COLUMNS}: a STUB room * holding only the fields the feeds filter and sort on, not the whole blob. The feeds rank @@ -2473,7 +2488,7 @@ function roomsByTagsQuery( where = '', columns = ROOM_COLUMNS ): { sql: string; binds: string[] } { - // `where` is the caller's row filter ({@link LISTABLE_WHERE} or {@link PUBLIC_WHERE}) — + // `where` is the caller's row filter ({@link LISTABLE_WHERE} or {@link SEARCHABLE_WHERE}) — // unqualified, which is unambiguous under either shape below. It matters most when // `tagSets` is EMPTY: that branch is the full scan every pseudo-tag feed still runs. const filter = where === '' ? '' : ` WHERE ${where}` @@ -3338,11 +3353,11 @@ function roomHasAnyTag(room: Room, tags: Set): boolean { } /** - * Search public, non-dorm rooms. The query is split into terms (space/`+`): - * `#tag` terms match the room's Tags; plain terms match the room name + * Search public, non-dorm rooms not opted out of search. The query is split into terms + * (space/`+`): `#tag` terms match the room's Tags; plain terms match the room name * (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`. - * The rows narrow in SQL — public, non-dorm ({@link PUBLIC_WHERE}) — and the name terms - * match in memory over what comes back. + * The rows narrow in SQL — public, non-dorm, searchable ({@link SEARCHABLE_WHERE}) — and + * the name terms match in memory over what comes back. * * `#community` is the one tag term that isn't a tag lookup — see {@link COMMUNITY_TAG}. */ @@ -3370,12 +3385,12 @@ export async function searchRooms( const tagSets = tagTerms .filter((tag) => tag !== COMMUNITY_TAG) .map((tag) => [tag, ...(TAG_ALIASES[tag] ?? [])]) - const { sql, binds } = roomsByTagsQuery(tagSets, PUBLIC_WHERE) + const { sql, binds } = roomsByTagsQuery(tagSets, SEARCHABLE_WHERE) const { results } = await db .prepare(sql) .bind(...binds) .all() - let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) + let rooms = parseAll(results).filter(isSearchable) // The same test the hot feed's `community` chip applies: every room a player made, which // is every room the Coach account doesn't own. It narrows the other terms rather than @@ -3403,8 +3418,9 @@ export async function searchRooms( * which is the whole point of the endpoint: a suggestion that returns nothing is worse * than no suggestion. So the candidates are drawn from the two things that search matches * — room NAMES for a plain term, and TAGS for a `#tag` term — over the same public, - * non-dorm rooms search itself considers. A tag comes back with its `#` so submitting the - * suggestion verbatim searches by tag rather than for a room called "horror". + * non-dorm, searchable rooms search itself considers. A room opted out of search must not + * leak through its name or tags as a suggestion. A tag comes back with its `#` so + * submitting the suggestion verbatim searches by tag rather than for a room called "horror". * * A query starting with `#` is asking for tags, so only tags are suggested. Otherwise * names come first (the likelier intent), then tags, and within each, matches that START @@ -3425,13 +3441,11 @@ export async function autocompleteRoomSearch( if (q === '' || take <= 0) return [] const { results } = await db - .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${PUBLIC_WHERE}`) + .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${SEARCHABLE_WHERE}`) .all() // Tags attached up front: suggestions are drawn from them, and this reads every candidate // room for its NAME regardless, so the tags cost one extra query rather than a second scan. - const rooms = (await parseAllWithTags(db, results)).filter( - (r) => r.IsDorm !== true && r.Accessibility === 1 - ) + const rooms = (await parseAllWithTags(db, results)).filter(isSearchable) const tagQuery = q.startsWith('#') const term = tagQuery ? q.slice(1) : q @@ -3515,6 +3529,15 @@ function isListable(room: Room): boolean { return room.IsDorm !== true && room.Accessibility === 1 && room.ExcludeFromLists !== true } +/** + * True when the room may come back from a SEARCH: public, not a dorm, and not opted out of + * search. The search-side twin of {@link isListable} — `ExcludeFromSearch` is its own flag, + * so neither test implies the other; see {@link SEARCHABLE_WHERE}. + */ +function isSearchable(room: Room): boolean { + return room.IsDorm !== true && room.Accessibility === 1 && room.ExcludeFromSearch !== true +} + /** * True when the room is a PLAYER's rather than one of this server's stock ones — the same * test {@link COMMUNITY_TAG} applies, since the Coach account owns every seeded room.