rooms appearing in search

This commit is contained in:
Devin Zuczek
2026-06-15 00:49:21 -04:00
parent 0dbd388249
commit 2dbc8579d9
3 changed files with 99 additions and 1 deletions
+51
View File
@@ -70,3 +70,54 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom
.all<RoomRow>()
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<string, string[]> = {
recroomoriginal: ['rro'],
}
/** True if the room carries any of the given (lowercased) tags. */
function roomHasAnyTag(room: Room, tags: Set<string>): boolean {
const roomTags = room.Tags
if (!Array.isArray(roomTags)) return false
return roomTags.some((t) => {
const value = (t as Record<string, unknown> | 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<RoomRow>()
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 }
}
+11 -1
View File
@@ -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<App>()
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.
@@ -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}`)