diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index 0637205..00828f2 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -70,3 +70,54 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom .all() return parseAll(results) } + +/** + * Search-tag aliases: a queried `#tag` also matches these stored tag names. + * The client's pinned filters don't always match how rooms are tagged (e.g. it + * searches `recroomoriginal`, but rooms are tagged `rro`). + */ +const TAG_ALIASES: Record = { + recroomoriginal: ['rro'], +} + +/** True if the room carries any of the given (lowercased) tags. */ +function roomHasAnyTag(room: Room, tags: Set): boolean { + const roomTags = room.Tags + if (!Array.isArray(roomTags)) return false + return roomTags.some((t) => { + const value = (t as Record | null)?.Tag + return typeof value === 'string' && tags.has(value.toLowerCase()) + }) +} + +/** + * 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 + * (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`. + * The dataset is small, so this filters in memory rather than in SQL. + */ +export async function searchRooms( + db: D1Database, + query: string, + skip: number, + take: number +): Promise<{ Results: Room[]; TotalResults: number }> { + const q = query.trim().toLowerCase() + if (q === '') return { Results: [], TotalResults: 0 } + const terms = q.split(/[\s+]+/).filter(Boolean) + + const { results } = await db.prepare('SELECT data FROM rooms').all() + let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) + + for (const term of terms) { + if (term.startsWith('#')) { + const tag = term.slice(1) + const accepted = new Set([tag, ...(TAG_ALIASES[tag] ?? [])]) + rooms = rooms.filter((r) => roomHasAnyTag(r, accepted)) + } else { + rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term)) + } + } + + return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } +} diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 67f213d..9b499eb 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from './jwt' -import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db' +import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds, searchRooms } from './rooms-db' import type { Context } from 'hono' import type { App } from './context' @@ -108,6 +108,16 @@ const app = new Hono() return c.json(room ?? {}) }) + // 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 }`. + .get('/rooms/search', async (c) => { + const query = c.req.query('query') ?? '' + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30 + return c.json(await searchRooms(c.env.DB, query, skip, take)) + }) + // Bulk room lookup by `id` or `name` — returns an array of matched rooms (the // client calls this bare on the rooms host). Rooms not in D1 are simply absent // from the result; the client treats an empty result as NoSuchRoom. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index f99e3d4..aa4cc0f 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -110,6 +110,43 @@ describe('rooms endpoints', () => { expect(other).toEqual([]) }) + it('GET /rooms/search returns a paginated { Results, TotalResults }', async () => { + // Name-term search resolves a known public room. + const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=reccenter&skip=0&take=100`) + expect(res.status).toBe(200) + const body = (await res.json()) as { Results: Array<{ Name: string }>; TotalResults: number } + expect(body.TotalResults).toBeGreaterThanOrEqual(1) + expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true) + }) + + it('GET /rooms/search excludes dorms and respects pagination shape', async () => { + const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=dormroom`) + expect(res.status).toBe(200) + const body = (await res.json()) as { Results: unknown[]; TotalResults: number } + // The dorm is non-public/dorm, so a name search for it returns nothing. + expect(body).toEqual({ Results: [], TotalResults: 0 }) + }) + + it('GET /rooms/search?query=#tag returns 200 (tag search)', async () => { + const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=%23Quest+%23recroomoriginal`) + expect(res.status).toBe(200) + const body = (await res.json()) as { Results: unknown[]; TotalResults: number } + expect(Array.isArray(body.Results)).toBe(true) + expect(typeof body.TotalResults).toBe('number') + }) + + it('GET /rooms/search aliases #recroomoriginal to the rro tag', async () => { + // Rooms are tagged `rro`, not `recroomoriginal` — the alias bridges them. + const aliased = (await ( + await SELF.fetch(`${ORIGIN}/rooms/search?query=%23recroomoriginal`) + ).json()) as { TotalResults: number } + const direct = (await (await SELF.fetch(`${ORIGIN}/rooms/search?query=%23rro`)).json()) as { + TotalResults: number + } + expect(aliased.TotalResults).toBe(direct.TotalResults) + expect(aliased.TotalResults).toBeGreaterThan(0) + }) + it('GET /photon_access_token (bare + /roomserver) returns permissions', async () => { for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { const res = await SELF.fetch(`${ORIGIN}${path}`)