implement stubs for /similar, visited, favroited, ownedby...implement clone

This commit is contained in:
Devin Zuczek
2026-06-30 17:37:25 -04:00
parent ce5a3551fa
commit 2cc01a5912
3 changed files with 577 additions and 29 deletions
+123 -20
View File
@@ -5,11 +5,17 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from './jwt'
import {
cloneRoom,
getBaseRooms,
getFavoritedRooms,
getHotRooms,
getInteraction,
getRoomById,
getRoomByName,
getRoomsByCreator,
getRoomsByIds,
getSimilarRooms,
getVisitedRooms,
searchRooms,
toggleCheer,
toggleFavorite,
@@ -72,16 +78,30 @@ function photonAccessToken() {
}
}
/** The account whose owned rooms to return: the Bearer token's `sub`, falling
* back to account 1 (the stub player) when there's no valid token. */
async function ownerId(c: Context<App>): Promise<number> {
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
async function authedAccountId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? ''
if (authHeader.toLowerCase().startsWith('bearer ')) {
const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length))
const id = sub ? Number.parseInt(sub, 10) : Number.NaN
if (!Number.isNaN(id)) return id
}
return 1
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length))
const id = sub ? Number.parseInt(sub, 10) : Number.NaN
return Number.isNaN(id) ? null : id
}
/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401)
}
/** Client envelope for room clone results: `{ success, error, value }`. */
function cloneResult(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
}
/** Rooms created/owned by the authed caller (shared by the createdby/ownedby routes). */
async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json(await getRoomsByCreator(c.env.DB, accountId))
}
const app = new Hono<App>()
@@ -127,6 +147,25 @@ const app = new Hono<App>()
return c.json(await searchRooms(c.env.DB, query, skip, take))
})
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
// defaults to 100). Returns `{ Results, TotalResults }` like search.
.get('/rooms/hot', async (c) => {
const tag = c.req.query('tag') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
})
// "Base" rooms — template rooms (tagged `base`) the client offers when creating
// a room. Returned regardless of accessibility. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
.get('/rooms/base', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getBaseRooms(c.env.DB, 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.
@@ -144,42 +183,106 @@ const app = new Hono<App>()
})
// Rooms created/owned by the caller (their dorm). The client calls all three.
.get('/roomserver/rooms/createdby/me', async (c) =>
c.json(await getRoomsByCreator(c.env.DB, await ownerId(c)))
)
.get('/rooms/ownedby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))))
.get('/rooms/createdby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))))
// Auth-gated — no token is a 401, never account 1.
.get('/roomserver/rooms/createdby/me', ownedRooms)
.get('/rooms/ownedby/me', ownedRooms)
.get('/rooms/createdby/me', ownedRooms)
// Rooms the caller has favorited (from the interaction table). Auth-gated.
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
// other room-source `*by/me` lists the client loads.
.get('/rooms/favoritedby/me', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
})
// Rooms the caller has visited (interaction rows with a last-visited time).
// Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/rooms/visitedby/me', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
})
// The current player's interaction state with a room (cheered/favorited/last
// visited), read from the `interaction` table.
// visited), read from the `interaction` table. Auth-gated.
.get('/rooms/:roomId{[0-9]+}/interactionby/me', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await getInteraction(
c.env.DB,
await ownerId(c),
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
// Toggle the player's cheer/favorite on a room. Both are PUTs that flip the
// stored flag and return the updated interaction.
// Toggle the player's cheer/favorite on a room. Both are auth-gated PUTs that
// flip the stored flag and return the updated interaction.
.put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await toggleCheer(
c.env.DB,
await ownerId(c),
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
.put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const interaction = await toggleFavorite(
c.env.DB,
await ownerId(c),
accountId,
Number.parseInt(c.req.param('roomId'), 10)
)
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
})
// Clone a room into a new one owned by the caller, using the `name` form field
// (also accepted as a query param). Auth is required — no valid token is a 401,
// with no stub-account fallback. Returns the `{ success, error, value }` envelope
// the client expects; business failures are 200 with success:false.
.post('/rooms/:roomId{[0-9]+}/clone', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.name ?? c.req.query('name') ?? ''
const name = typeof raw === 'string' ? raw.trim() : ''
if (name === '') return cloneResult(c, null, 'You must enter a name for your room.')
if (await getRoomByName(c.env.DB, name)) {
return cloneResult(c, null, 'A room with that name already exists!')
}
const room = await cloneRoom(
c.env.DB,
Number.parseInt(c.req.param('roomId'), 10),
name,
accountId
)
if (!room) return cloneResult(c, null, "You can't clone this room!")
return cloneResult(c, room)
})
// 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.
.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
return c.json(
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
)
})
// Single room by id. 404 when the room isn't in D1. Ignores the
// include/unityAsset* query params.
.get('/rooms/:roomId{[0-9]+}', async (c) => {