mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
more routes
This commit is contained in:
@@ -78,6 +78,26 @@ export async function cloneRoom(
|
||||
return cloned
|
||||
}
|
||||
|
||||
/** Set a room's Description in place (the caller is responsible for the owner check). */
|
||||
export async function setRoomDescription(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE rooms SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
|
||||
.bind(roomId, description)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
|
||||
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE rooms SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1")
|
||||
.bind(roomId, name)
|
||||
.run()
|
||||
}
|
||||
|
||||
interface RoomRow {
|
||||
data: string
|
||||
}
|
||||
@@ -339,19 +359,21 @@ export async function getHotRooms(
|
||||
/**
|
||||
* Rooms similar to a target room: public, non-dorm rooms (excluding the target)
|
||||
* that share at least one tag with it, ranked by shared-tag count then
|
||||
* engagement. Returns a bare array; empty if the target isn't in D1 or is
|
||||
* untagged. Paginated via skip/take. Small dataset, so done in memory.
|
||||
* engagement. Returns a paginated `{ Results, TotalResults }` (the client's
|
||||
* RoomSimilarity source expects an object, not a bare array); empty if the target
|
||||
* isn't in D1 or is untagged. Small dataset, so done in memory.
|
||||
*/
|
||||
export async function getSimilarRooms(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||||
const empty = { Results: [] as Room[], TotalResults: 0 }
|
||||
const target = await getRoomById(db, roomId)
|
||||
if (!target) return []
|
||||
if (!target) return empty
|
||||
const targetTags = new Set(roomTags(target))
|
||||
if (targetTags.size === 0) return []
|
||||
if (targetTags.size === 0) return empty
|
||||
|
||||
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
|
||||
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
||||
@@ -374,7 +396,8 @@ export async function getSimilarRooms(
|
||||
hotScore(b.room) - hotScore(a.room) ||
|
||||
roomIdOf(a.room) - roomIdOf(b.room)
|
||||
)
|
||||
return scored.slice(skip, skip + take).map((x) => x.room)
|
||||
const rooms = scored.map((x) => x.room)
|
||||
return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getSimilarRooms,
|
||||
getVisitedRooms,
|
||||
searchRooms,
|
||||
setRoomDescription,
|
||||
setRoomName,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
} from './rooms-db'
|
||||
@@ -92,6 +94,22 @@ function unauthorized(c: Context<App>) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
|
||||
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
|
||||
*/
|
||||
function roomResult(
|
||||
c: Context<App>,
|
||||
fields: { Success: boolean; Value?: unknown; ErrorId?: string; Error?: string }
|
||||
) {
|
||||
return c.json({
|
||||
Success: fields.Success,
|
||||
Value: fields.Value ?? null,
|
||||
ErrorId: fields.ErrorId ?? null,
|
||||
Error: fields.Error ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Client envelope for room clone results: `{ success, error, value }`. */
|
||||
function cloneResult(c: Context<App>, value: unknown, error = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
@@ -273,8 +291,87 @@ const app = new Hono<App>()
|
||||
return cloneResult(c, room)
|
||||
})
|
||||
|
||||
// Update a room's description. Auth-gated (401) and owner-only. Business results
|
||||
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
.put('/rooms/:roomId{[0-9]+}/description', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const description = typeof body.description === 'string' ? body.description : ''
|
||||
await setRoomDescription(c.env.DB, roomId, description)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
|
||||
// and not already taken by another room. Business results use the
|
||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
|
||||
.put('/rooms/:roomId{[0-9]+}/name', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
|
||||
// Reject if a different room already uses this name (case-insensitive).
|
||||
const existing = await getRoomByName(c.env.DB, name)
|
||||
if (existing && existing.RoomId !== roomId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.AlreadyExists',
|
||||
Error: 'A room with that name already exists!',
|
||||
})
|
||||
}
|
||||
|
||||
await setRoomName(c.env.DB, roomId, name)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
|
||||
// defaults to 100). Returns a bare array; empty when the room is unknown/untagged.
|
||||
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
|
||||
// unknown/untagged.
|
||||
.get('/rooms/:roomId{[0-9]+}/similar', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
|
||||
@@ -289,28 +289,32 @@ describe('rooms endpoints', () => {
|
||||
expect(body.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/similar returns a bare array of tag-sharing rooms (excluding self)', async () => {
|
||||
it('GET /rooms/:id/similar returns { Results, TotalResults } of tag-sharing rooms (excluding self)', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }>
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
const body = (await res.json()) as {
|
||||
Results: Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }>
|
||||
TotalResults: number
|
||||
}
|
||||
expect(body.Results.length).toBeGreaterThan(0)
|
||||
expect(body.TotalResults).toBeGreaterThanOrEqual(body.Results.length)
|
||||
// Never includes the target room itself.
|
||||
expect(body.some((r) => r.RoomId === 2)).toBe(false)
|
||||
expect(body.Results.some((r) => r.RoomId === 2)).toBe(false)
|
||||
// Every result shares the `rro` tag RecCenter (room 2) carries.
|
||||
expect(body.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'rro'))).toBe(true)
|
||||
expect(body.Results.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'rro'))).toBe(true)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/similar respects take pagination', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar?skip=0&take=3`)
|
||||
const body = (await res.json()) as unknown[]
|
||||
expect(body.length).toBeLessThanOrEqual(3)
|
||||
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
|
||||
expect(body.Results.length).toBeLessThanOrEqual(3)
|
||||
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/similar returns [] for a room not in D1', async () => {
|
||||
it('GET /rooms/:id/similar returns an empty result for a room not in D1', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/99999/similar`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/clone clones a base room into a new owned room', async () => {
|
||||
@@ -401,6 +405,73 @@ describe('rooms endpoints', () => {
|
||||
expect(missing).toMatchObject({ success: false, value: null })
|
||||
})
|
||||
|
||||
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(sub ? await bearer(sub) : {}),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
// Room-mutation envelope helper.
|
||||
type RoomResult = { Success: boolean; Value: unknown; ErrorId: string | null; Error: string | null }
|
||||
const bodyOf = async (res: Response) => (await res.json()) as RoomResult
|
||||
|
||||
it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => {
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401)
|
||||
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/description', { description: 'x' }, '999'))).toMatchObject(
|
||||
{ Success: false, ErrorId: 'Rooms.NotOwner' }
|
||||
)
|
||||
// Unknown room → Rooms.DoesntExist envelope.
|
||||
expect(await bodyOf(await putForm('/rooms/99999/description', { description: 'x' }, '1'))).toMatchObject(
|
||||
{ Success: false, ErrorId: 'Rooms.DoesntExist', Error: 'This room does not exist!' }
|
||||
)
|
||||
|
||||
// Owner updates it, and it persists.
|
||||
const ok = await putForm('/rooms/2/description', { description: 'blah blah blah' }, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true, Value: null, ErrorId: null, Error: null })
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Description: string }
|
||||
expect(room.Description).toBe('blah blah blah')
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
|
||||
// Wrong owner / unknown room → Success:false envelopes.
|
||||
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'Whatever' }, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
})
|
||||
expect(await bodyOf(await putForm('/rooms/99999/name', { name: 'Whatever' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
})
|
||||
// Empty name → Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/name', { name: ' ' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
})
|
||||
// A name already used by a different room (GoldenTrophy is room 12).
|
||||
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'GoldenTrophy' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.AlreadyExists',
|
||||
Error: 'A room with that name already exists!',
|
||||
})
|
||||
|
||||
// Owner renames to a free name, and it persists (findable by the new name).
|
||||
const ok = await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
|
||||
RoomId: number
|
||||
}
|
||||
expect(room.RoomId).toBe(2)
|
||||
})
|
||||
|
||||
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}`)
|
||||
|
||||
Reference in New Issue
Block a user