mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
rooms appearing in search
This commit is contained in:
@@ -70,3 +70,54 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom
|
|||||||
.all<RoomRow>()
|
.all<RoomRow>()
|
||||||
return parseAll(results)
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { validateAndGetAccountId } from './jwt'
|
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 { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
@@ -108,6 +108,16 @@ const app = new Hono<App>()
|
|||||||
return c.json(room ?? {})
|
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
|
// 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
|
// 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.
|
// from the result; the client treats an empty result as NoSuchRoom.
|
||||||
|
|||||||
@@ -110,6 +110,43 @@ describe('rooms endpoints', () => {
|
|||||||
expect(other).toEqual([])
|
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 () => {
|
it('GET /photon_access_token (bare + /roomserver) returns permissions', async () => {
|
||||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
||||||
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user