misc rooms endpoints, friends

This commit is contained in:
Devin Zuczek
2026-07-08 13:22:06 -04:00
parent 390843c679
commit a1d9fc17f0
22 changed files with 823 additions and 123 deletions
+27
View File
@@ -137,6 +137,33 @@ export async function getAccountByUsername(
)
}
/** Default cap on how many matches `searchAccounts` returns. */
export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
/**
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
* alphabetically. Backed by the indexed `username_lower` generated column, so the
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
*/
export async function searchAccounts(
db: D1Database,
name: string,
limit = SEARCH_LIMIT
): Promise<Account[]> {
const q = name.trim().toLowerCase()
if (q === '') return []
const { results } = await db
.prepare(
`SELECT data FROM accounts WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
)
.bind(`${escapeLike(q)}%`, limit)
.all<AccountRow>()
return parseAll(results)
}
/** Look up multiple accounts by AccountId (order not guaranteed). */
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
if (ids.length === 0) return []
+11
View File
@@ -9,6 +9,7 @@ import {
getAccount,
getAccountByUsername,
getAccountsByIds,
searchAccounts,
updateAccount,
} from './accounts-db'
import { validateAndGetAccountId } from './jwt'
@@ -151,6 +152,16 @@ const app = new Hono<App>()
return c.json(toSelfAccountDto(account))
})
// ---- Search --------------------------------------------------------------
// Prefix-search accounts by username (`?name=`). Returns a bare array of public
// account DTOs, ordered alphabetically. Registered before `/account/:id` so the
// static `search` path wins over the param route.
.get('/account/search', async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
return c.json(accounts.map(toAccountDto))
})
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', async (c) => {
@@ -95,6 +95,20 @@ describe('public endpoints', () => {
expect(accounts[1].username).toBe('Player2')
})
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
// "Coach" (seeded uid 1) matches the "coa" prefix, case-insensitively.
expect(accounts.some((a) => a.accountId === 1 && a.username === 'Coach')).toBe(true)
// A non-matching prefix yields nothing.
const none = await exports.default.fetch(`${ORIGIN}/account/search?name=zzzznope`)
expect(await none.json()).toEqual([])
// An empty query yields nothing (no full-table dump).
const empty = await exports.default.fetch(`${ORIGIN}/account/search?name=`)
expect(await empty.json()).toEqual([])
})
test('GET /account/:id/bio returns an empty bio', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
+24
View File
@@ -0,0 +1,24 @@
-- Friendship / relationship storage. Unlike the JSON-blob tables in this shared
-- database, a relationship is genuinely columnar, so it gets a normal relational
-- table (mirror of the Go/GORM `Relationship` model). Owned by the `api` worker;
-- generated from src/relationships-db.ts (SCHEMA_DDL) — keep in sync.
--
-- Exactly one row exists per unordered pair of players: the initiator is the
-- `requester`, the other is the `target`. `relationship_type` is stored from the
-- requester's point of view (0 None, 1 FriendRequestSent, 2 FriendRequestReceived,
-- 3 Friend); the target's projection flips Sent<->Received.
CREATE TABLE IF NOT EXISTS relationship (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relationship_type INTEGER NOT NULL DEFAULT 0,
requester_favorited INTEGER NOT NULL DEFAULT 0,
requester_ignored INTEGER NOT NULL DEFAULT 0,
requester_muted INTEGER NOT NULL DEFAULT 0,
target_favorited INTEGER NOT NULL DEFAULT 0,
target_ignored INTEGER NOT NULL DEFAULT 0,
target_muted INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id);
CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id);
+1
View File
@@ -12,6 +12,7 @@
"deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"migrate": "run-wrangler-migrate",
"test": "run-vitest"
},
"dependencies": {
+85
View File
@@ -153,6 +153,91 @@ export async function getImagesByPlayer(
.slice(skip, skip + take)
}
/** Default number of recent images the slideshow feed returns. */
export const SLIDESHOW_LIMIT = 130
/** The slideshow projection of an image — creator username + room name joined in. */
export interface SlideshowImage {
SavedImageId: number
ImageName: string
Username: string
RoomName: string | null
RoomId: number | null
SavedImageType: number
PlayerEventId: number | null
Accessibility: number
PlayerIds: number[]
}
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
const placeholders = (n: number): string =>
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
/** Map account ids → username, resolved from the shared accounts table. */
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
if (ids.length === 0) return new Map()
const { results } = await db
.prepare(
`SELECT account_id AS id, json_extract(data, '$.username') AS username
FROM accounts WHERE account_id IN (${placeholders(ids.length)})`
)
.bind(...ids)
.all<{ id: number; username: string }>()
return new Map(results.map((r) => [r.id, r.username]))
}
/** Map room ids → room name, resolved from the shared rooms table. */
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
if (ids.length === 0) return new Map()
const { results } = await db
.prepare(
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
FROM room WHERE room_id IN (${placeholders(ids.length)})`
)
.bind(...ids)
.all<{ id: number; name: string }>()
return new Map(results.map((r) => [r.id, r.name]))
}
/**
* The global slideshow feed — the most recent publicly-listable images across all
* rooms (Accessibility 0 or 1), newest first, capped at `limit`. Each row is joined
* to its creator's username and (if any) its room's name. Returns the projected
* SlideshowImage shape. Usernames/room names are resolved in two batched lookups to
* avoid an N+1 across the (at most `limit`) images.
*/
export async function getSlideshowImages(
db: D1Database,
limit = SLIDESHOW_LIMIT
): Promise<SlideshowImage[]> {
const { results } = await db
.prepare(
`SELECT data FROM image
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
ORDER BY id DESC LIMIT ?1`
)
.bind(limit)
.all<ImageRow>()
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
const roomNames = await getRoomNames(db, roomIds)
return images.map((img) => ({
SavedImageId: img.Id,
ImageName: img.ImageName,
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
RoomId: img.RoomId,
SavedImageType: img.Type,
PlayerEventId: img.PlayerEventId,
Accessibility: img.Accessibility,
PlayerIds: img.TaggedPlayerIds,
}))
}
/**
* A player's photo feed — the public images they took plus the ones they're
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
+265
View File
@@ -0,0 +1,265 @@
/**
* Friendship / relationship storage on the shared `recflare` D1 database.
*
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
* relationship is genuinely columnar, so it gets a normal relational table
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
* unordered pair of players: the player who initiated is the `requester`, the
* other is the `target`. `relationship_type` is stored from the requester's
* point of view; when we project the row for the *target* we flip
* Sent↔Received (Friend/None are symmetric).
*
* The `api` worker owns this schema/migration (migrations/0001_relationship.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*/
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
export enum RelationshipType {
None = 0,
FriendRequestSent = 1,
FriendRequestReceived = 2,
Friend = 3,
}
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS relationship (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relationship_type INTEGER NOT NULL DEFAULT 0,
requester_favorited INTEGER NOT NULL DEFAULT 0,
requester_ignored INTEGER NOT NULL DEFAULT 0,
requester_muted INTEGER NOT NULL DEFAULT 0,
target_favorited INTEGER NOT NULL DEFAULT 0,
target_ignored INTEGER NOT NULL DEFAULT 0,
target_muted INTEGER NOT NULL DEFAULT 0
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
]
/** A stored relationship row (snake_case columns, one row per player pair). */
interface RelationshipRow {
requester_id: number
target_id: number
relationship_type: number
requester_favorited: number
requester_ignored: number
requester_muted: number
target_favorited: number
target_ignored: number
target_muted: number
}
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
export interface RelationshipResponse {
Favorited: number
Ignored: number
Muted: number
PlayerID: number
RelationshipType: RelationshipType
}
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
function flipType(type: number): RelationshipType {
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
return type as RelationshipType
}
/**
* Project a stored row into the RelationshipResponse for `playerId` (who must be
* one of the pair). `PlayerID` is the *other* player; the type and the
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
*/
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
const isRequester = row.requester_id === playerId
return {
PlayerID: isRequester ? row.target_id : row.requester_id,
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
Muted: isRequester ? row.requester_muted : row.target_muted,
}
}
/** Find the single row for an unordered pair (either direction), or null. */
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
return db
.prepare(
`SELECT * FROM relationship
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(a, b)
.first<RelationshipRow>()
}
/**
* All of a player's relationships, projected from that player's point of view.
* `None` rows are omitted (a removed friend leaves no relationship to report).
*/
export async function getRelationshipsForPlayer(
db: D1Database,
playerId: number
): Promise<RelationshipResponse[]> {
const { results } = await db
.prepare(
`SELECT * FROM relationship
WHERE requester_id = ?1 OR target_id = ?1`
)
.bind(playerId)
.all<RelationshipRow>()
return results
.filter((row) => row.relationship_type !== RelationshipType.None)
.map((row) => toResponse(row, playerId))
}
/**
* Persist `type` for the pair, with `requesterId` recorded as the row's
* requester. Inserts a new row or, if one already exists for the pair (either
* direction), rewrites it so the requester is normalized to `requesterId` and
* the flags are preserved for whichever side each player is on. Returns the
* relationship from `requesterId`'s point of view.
*/
async function upsertPair(
db: D1Database,
requesterId: number,
targetId: number,
type: RelationshipType
): Promise<RelationshipResponse> {
const existing = await findPair(db, requesterId, targetId)
if (!existing) {
await db
.prepare(
`INSERT INTO relationship (requester_id, target_id, relationship_type)
VALUES (?1, ?2, ?3)`
)
.bind(requesterId, targetId, type)
.run()
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
}
// Keep each player's flags with that player as the row is normalized to
// requester = requesterId.
const reqIsRequester = existing.requester_id === requesterId
const reqFlags = {
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
}
const tgtFlags = {
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
}
await db
.prepare(
`UPDATE relationship
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(
requesterId,
targetId,
type,
reqFlags.favorited,
reqFlags.ignored,
reqFlags.muted,
tgtFlags.favorited,
tgtFlags.ignored,
tgtFlags.muted
)
.run()
return {
PlayerID: targetId,
RelationshipType: type,
Favorited: reqFlags.favorited,
Ignored: reqFlags.ignored,
Muted: reqFlags.muted,
}
}
/**
* Send a friend request from `requesterId` to `targetId`. If the target already
* has a pending request out to the requester, the two become friends instead
* (the request crosses an existing one). Already-friends is left unchanged.
* Returns the relationship from the requester's point of view.
*/
export async function sendFriendRequest(
db: D1Database,
requesterId: number,
targetId: number
): Promise<RelationshipResponse> {
const existing = await findPair(db, requesterId, targetId)
if (existing) {
if (existing.relationship_type === RelationshipType.Friend) {
return toResponse(existing, requesterId)
}
// The target already requested us → crossing requests become a friendship.
if (
existing.requester_id === targetId &&
existing.relationship_type === RelationshipType.FriendRequestSent
) {
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
}
}
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
}
/**
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
* Friend when a request from `otherId` is actually pending; otherwise the
* current state is returned unchanged. Returns the relationship from the
* accepter's point of view.
*/
export async function acceptFriendRequest(
db: D1Database,
accepterId: number,
otherId: number
): Promise<RelationshipResponse> {
const existing = await findPair(db, accepterId, otherId)
if (
existing &&
existing.requester_id === otherId &&
existing.relationship_type === RelationshipType.FriendRequestSent
) {
// upsertPair projects for the requester (otherId); the accepter is the target,
// so re-project the written row from the accepter's point of view.
await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
const updated = await findPair(db, accepterId, otherId)
if (updated) return toResponse(updated, accepterId)
}
return existing
? toResponse(existing, accepterId)
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
}
/**
* Directly make `requesterId` and `targetId` friends (no pending request step).
* Returns the relationship from the requester's point of view.
*/
export async function addFriend(
db: D1Database,
requesterId: number,
targetId: number
): Promise<RelationshipResponse> {
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
}
/**
* Remove any relationship between the two players (unfriend / cancel request /
* decline). Deletes the row entirely so neither side reports a relationship.
*/
export async function removeFriend(db: D1Database, a: number, b: number): Promise<void> {
await db
.prepare(
`DELETE FROM relationship
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(a, b)
.run()
}
+1 -1
View File
@@ -17,6 +17,6 @@ const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.da
export async function getRoomById(db: D1Database, roomId: number): Promise<Room | null> {
return parseOne(
await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first<RoomRow>()
await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first<RoomRow>()
)
}
+2 -2
View File
@@ -26,13 +26,13 @@ export const configRoutes = new Hono<App>({ strict: false })
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 0.025,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
"^.*$",
VersionRegex: '.*',
})
)
+13
View File
@@ -6,6 +6,7 @@ import {
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
getSlideshowImages,
} from '../images-db'
import { authedId, unauthorized } from '../http'
@@ -133,6 +134,18 @@ export const imageRoutes = new Hono<App>({ strict: false })
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
})
// Global slideshow feed — the most recent publicly-listable images (Accessibility
// 0 or 1) across all rooms, newest first, each joined to its creator's username
// and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a
// short (2-minute) cache hint the client refreshes against.
.get('/api/images/v1/slideshow', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const Images = await getSlideshowImages(c.env.DB)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
})
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
+84 -1
View File
@@ -1,9 +1,92 @@
import { Hono } from 'hono'
import {
acceptFriendRequest,
addFriend,
getRelationshipsForPlayer,
removeFriend,
sendFriendRequest,
} from '../relationships-db'
import { authedId, unauthorized } from '../http'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Read the other player's id from a relationship-mutation request. The exact wire
* shape is still TBD, so this is liberal: it accepts `playerId`/`id` as a query
* param and `PlayerId`/`playerId`/`Id` from a JSON or form body. Returns null when
* no integer id is present.
*/
async function targetPlayerId(c: Context<App>): Promise<number | null> {
const fromQuery = c.req.query('playerId') ?? c.req.query('id')
if (fromQuery !== undefined) {
const n = Number.parseInt(fromQuery, 10)
if (!Number.isNaN(n)) return n
}
// Body may be JSON or form-encoded; Hono's parseBody only handles the latter.
const contentType = c.req.header('content-type') ?? ''
const body = contentType.includes('application/json')
? await c.req.json<Record<string, unknown>>().catch(() => ({}) as Record<string, unknown>)
: ((await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>)
const raw = body.PlayerId ?? body.playerId ?? body.Id
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw
if (typeof raw === 'string') {
const n = Number.parseInt(raw, 10)
if (!Number.isNaN(n)) return n
}
return null
}
// ---- Social ----------------------------------------------------------------
export const socialRoutes = new Hono<App>({ strict: false })
.get('/api/relationships/v2/get', (c) => c.json([]))
// The authed player's relationships, projected from their point of view — a bare
// array of RelationshipResponse. Auth-gated.
.get('/api/relationships/v2/get', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
})
// Send a friend request to another player (the target arrives as `?id=`). The
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
// matched any method). Auth-gated. Returns the resulting relationship from the
// caller's point of view.
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await sendFriendRequest(c.env.DB, id, target))
})
// Accept a pending friend request from another player (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/acceptfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await acceptFriendRequest(c.env.DB, id, target))
})
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
await removeFriend(c.env.DB, id, target)
return c.json({ success: true })
})
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/addfriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await addFriend(c.env.DB, id, target))
})
.get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
+134 -4
View File
@@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../api.app'
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import type { Env } from '../../context'
import type { SavedImage } from '../../images-db'
@@ -40,14 +41,14 @@ beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms (
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
)`
).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// Accounts table (matching the auth worker's migration) — uploadsaved records
@@ -68,6 +69,9 @@ beforeAll(async () => {
// Images table (owned by the img worker) — uploadsaved records a row here.
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -131,8 +135,11 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
})
test('GET /api/relationships/v2/get returns empty array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer('99999'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
@@ -335,6 +342,53 @@ describe('images', () => {
expect(meta.CheerCount).toBe(0)
})
test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => {
// No token → 401.
expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401)
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id: 9001,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName: 'slide9001.jpg',
Description: null,
PlayerId: 42,
TaggedPlayerIds: [7, 8],
RoomId: 2,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
})
)
.run()
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Images: Array<Record<string, unknown>>
ValidTill: string
}
expect(body.ValidTill).toMatch(/Z$/)
const slide = body.Images.find((i) => i.SavedImageId === 9001)
expect(slide).toMatchObject({
SavedImageId: 9001,
ImageName: 'slide9001.jpg',
Username: 'Tester', // account 42 seeded above
RoomName: 'RecCenter', // room 2
RoomId: 2,
SavedImageType: 1,
Accessibility: 1,
PlayerIds: [7, 8],
})
})
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
// No token → 401.
@@ -564,3 +618,79 @@ describe('images', () => {
).toEqual([])
})
})
describe('relationships', () => {
// RelationshipType: 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend.
type Rel = { PlayerID: number; RelationshipType: number; Favorited: number }
// Call a relationship mutation as `sub`, targeting `playerId` — the real client
// shape: a GET with the target in `?id=`.
async function mutate(path: string, sub: string, playerId: number) {
return exports.default.fetch(`${ORIGIN}${path}?id=${playerId}`, {
headers: await bearer(sub),
})
}
// Fetch `sub`'s relationships, projected from their point of view.
async function relationships(sub: string): Promise<Rel[]> {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer(sub),
})
return (await res.json()) as Rel[]
}
test('GET /api/relationships/v2/get is auth-gated', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
})
test('mutations are auth-gated', async () => {
for (const path of [
'/api/relationships/v2/sendfriendrequest',
'/api/relationships/v2/acceptfriendrequest',
'/api/relationships/v2/removefriend',
'/api/relationships/v2/addfriend',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}?id=1`)
expect(res.status).toBe(401)
}
})
test('send → the two sides see Sent / Received; accept → both Friend; remove → gone', async () => {
// 500 sends 501 a request.
const sent = (await (await mutate('/api/relationships/v2/sendfriendrequest', '500', 501)).json()) as Rel
expect(sent).toMatchObject({ PlayerID: 501, RelationshipType: 1 })
// 500 sees it as Sent (1); 501 sees the mirror as Received (2).
expect(await relationships('500')).toEqual([{ PlayerID: 501, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 }])
expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }])
// 501 accepts → both are Friends (3).
const accepted = (await (await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500)).json()) as Rel
expect(accepted).toMatchObject({ PlayerID: 500, RelationshipType: 3 })
expect(await relationships('500')).toEqual([{ PlayerID: 501, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
// 500 removes → neither side has a relationship.
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
expect(await relationships('500')).toEqual([])
expect(await relationships('501')).toEqual([])
})
test('addfriend makes them friends directly', async () => {
const res = (await (await mutate('/api/relationships/v2/addfriend', '510', 511)).json()) as Rel
expect(res).toMatchObject({ PlayerID: 511, RelationshipType: 3 })
expect(await relationships('511')).toEqual([{ PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
})
test('crossing friend requests become a friendship', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '520', 521)
// 521 sends back to 520 → the crossing requests resolve to Friend for both.
const crossed = (await (await mutate('/api/relationships/v2/sendfriendrequest', '521', 520)).json()) as Rel
expect(crossed).toMatchObject({ PlayerID: 520, RelationshipType: 3 })
expect(await relationships('520')).toEqual([{ PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
})
test('a self-targeted request is rejected', async () => {
expect((await mutate('/api/relationships/v2/sendfriendrequest', '530', 530)).status).toBe(400)
})
})
+3 -3
View File
@@ -111,7 +111,7 @@
"EndTime": null,
"Key": "Backtrace.messageRegex",
"StartTime": null,
"Value": "^Cannot set the parent of the GameObject .* while its new parent|^\\>\\x2010x\\:\\x20|\\'LabelTheme\\' contains missing PaletteTheme reference on"
"Value": ".*"
},
{
"EndTime": null,
@@ -675,13 +675,13 @@
"EndTime": null,
"Key": "Growth.EnableInfluencerProgram",
"StartTime": null,
"Value": "true"
"Value": "false"
},
{
"EndTime": null,
"Key": "Growth.EnableProfilePhoneButton",
"StartTime": null,
"Value": "true"
"Value": "false"
},
{
"EndTime": null,
+8 -4
View File
@@ -4,14 +4,18 @@
"main": "src/api.app.ts",
"compatibility_date": "2025-09-20",
"compatibility_flags": ["nodejs_compat"],
// Shared `recflare` DB (bound read-only here). Created manually with
// `wrangler d1 create recflare`; its id lives in RECFLARE_D1 (see .env) and the
// "local" placeholder below is spliced out at deploy time.
// Shared `recflare` DB. Created manually with `wrangler d1 create recflare`; its
// id lives in RECFLARE_D1 (see .env) and the "local" placeholder below is
// spliced out at deploy time. The `api` worker owns the `relationships` table
// (schema/migration here); its own migrations_table keeps history separate from
// the other workers' migrations on the shared database.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local"
"database_id": "local",
"migrations_dir": "migrations",
"migrations_table": "d1_migrations_api"
}
],
// Image bucket shared with the `img` worker (which serves objects back by key).
+1 -1
View File
@@ -49,7 +49,7 @@ const PRESENCE_TTL = 900
* the heartbeat keeps them there.
*/
async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: number): Promise<void> {
const row = await env.DB.prepare('SELECT data FROM rooms WHERE room_id = ?1')
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(ORIENTATION_ROOM_ID)
.first<{ data: string }>()
if (!row) return
+2 -2
View File
@@ -27,12 +27,12 @@ beforeAll(async () => {
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms (
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 13,
+5 -5
View File
@@ -17,14 +17,14 @@ const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.da
export async function getRoomById(db: D1Database, roomId: number): Promise<Room | null> {
return parseOne(
await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first<RoomRow>()
await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first<RoomRow>()
)
}
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
return parseOne(
await db
.prepare('SELECT data FROM rooms WHERE name_lower = ?1')
.prepare('SELECT data FROM room WHERE name_lower = ?1')
.bind(name.toLowerCase())
.first<RoomRow>()
)
@@ -48,7 +48,7 @@ export async function getUsername(db: D1Database, accountId: number): Promise<st
export async function getDormRoom(db: D1Database, accountId: number): Promise<Room | null> {
return parseOne(
await db
.prepare('SELECT data FROM rooms WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1')
.prepare('SELECT data FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1')
.bind(accountId)
.first<RoomRow>()
)
@@ -69,7 +69,7 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID)
const idRow = await db
.prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM rooms')
.prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room')
.first<{ next: number }>()
const roomId = idRow?.next ?? 2
@@ -93,6 +93,6 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
SubRooms: [{ ...templateSub, CreatorAccountId: accountId }],
CreatedAt: new Date().toISOString(),
}
await db.prepare('INSERT INTO rooms (data) VALUES (?1)').bind(JSON.stringify(room)).run()
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(room)).run()
return room
}
+3 -3
View File
@@ -38,7 +38,7 @@ beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms (
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
@@ -46,7 +46,7 @@ beforeAll(async () => {
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
)`
).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -268,7 +268,7 @@ describe('auth-gated endpoints', () => {
const roomId = body.roomInstance.roomId
expect(roomId).toBeGreaterThan(2)
// …owned by the player and flagged IsDorm so they can save it.
const row = await env.DB.prepare('SELECT data FROM rooms WHERE room_id = ?1')
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(roomId)
.first<{ data: string }>()
expect(JSON.parse(row!.data)).toMatchObject({ CreatorAccountId: 42, IsDorm: true })
@@ -0,0 +1,6 @@
-- Rename the `rooms` table to `room`, matching the singular naming of the other
-- tables (`interaction`, `room_instance`). SQLite carries the generated columns
-- and the idx_rooms_* indexes over to the renamed table automatically, so this
-- is the whole change. SCHEMA_DDL in src/rooms-db.ts already creates `room`.
ALTER TABLE rooms RENAME TO room;
+52 -31
View File
@@ -4,14 +4,16 @@
* generated (virtual) columns extracted from that JSON and indexed. This keeps
* the room shape flexible while still allowing fast lookups by id/name/creator.
*
* `SCHEMA_DDL` mirrors `migrations/0001_init.sql`; the room data is seeded from
* `static/ImportRooms.json` by `migrations/0002_import_rooms.sql`. Tests apply
* `SCHEMA_DDL` then seed the imported rooms directly.
* `SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql`
* created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`);
* the room data is seeded from `static/ImportRooms.json` by
* `migrations/0002_import_rooms.sql`. Tests apply `SCHEMA_DDL` then seed the
* imported rooms directly.
*/
/** Schema DDL (mirror of migrations/0001_init.sql, sans the seed INSERT). */
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS rooms (
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) VIRTUAL,
@@ -19,9 +21,9 @@ export const SCHEMA_DDL: string[] = [
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON rooms (room_id)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON rooms (name_lower)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON rooms (creator_account_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`,
// Per-player interaction state with a room (cheered/favorited + last visit).
// One row per (player, room); cheer/favorite are toggled in place.
`CREATE TABLE IF NOT EXISTS interaction (
@@ -65,7 +67,7 @@ export async function cloneRoom(
if (!source || source.CloningAllowed === false) return null
const row = await db
.prepare('SELECT MAX(room_id) AS maxId FROM rooms')
.prepare('SELECT MAX(room_id) AS maxId FROM room')
.first<{ maxId: number | null }>()
const newRoomId = (row?.maxId ?? 0) + 1
@@ -93,7 +95,7 @@ export async function cloneRoom(
CreatedAt: new Date().toISOString(),
}
await db.prepare('INSERT INTO rooms (data) VALUES (?1)').bind(JSON.stringify(cloned)).run()
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(cloned)).run()
return cloned
}
@@ -104,7 +106,7 @@ export async function setRoomDescription(
description: string
): Promise<void> {
await db
.prepare("UPDATE rooms SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
.prepare("UPDATE room SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
.bind(roomId, description)
.run()
}
@@ -112,7 +114,7 @@ export async function setRoomDescription(
/** 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")
.prepare("UPDATE room SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1")
.bind(roomId, name)
.run()
}
@@ -120,7 +122,7 @@ export async function setRoomName(db: D1Database, roomId: number, name: string):
/** Set a room's ImageName in place (the caller is responsible for the owner check). */
export async function setRoomImage(db: D1Database, roomId: number, imageName: string): Promise<void> {
await db
.prepare("UPDATE rooms SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
.prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
.bind(roomId, imageName)
.run()
}
@@ -130,19 +132,38 @@ export async function setRoomImage(db: D1Database, roomId: number, imageName: st
* (case-insensitive). The caller supplies the already-loaded room (owner-checked)
* to avoid a re-read; the whole room JSON is rewritten. Returns the updated room.
*/
export async function addRoomTag(
/**
* Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so
* setting one clears any other main tag. Compared case-insensitively.
*/
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
export async function toggleRoomTag(
db: D1Database,
roomId: number,
room: Room,
tag: string
): Promise<Room> {
const tags = Array.isArray(room.Tags) ? (room.Tags as Array<Record<string, unknown>>) : []
if (!tags.some((t) => String(t?.Tag).toLowerCase() === tag.toLowerCase())) {
tags.push({ Tag: tag, Type: 0 })
const lower = tag.toLowerCase()
const tagLower = (t: Record<string, unknown>): string => String(t?.Tag).toLowerCase()
const existing = tags.findIndex((t) => tagLower(t) === lower)
// The client has no delete/patch endpoint — the same call toggles a tag: remove
// it if already present, add it otherwise. Adding a main tag is a radio pick, so
// it also clears any other main tag already set.
let nextTags: Array<Record<string, unknown>>
if (existing !== -1) {
nextTags = tags.filter((_, i) => i !== existing)
} else if (MAIN_TAGS.has(lower)) {
nextTags = [...tags.filter((t) => !MAIN_TAGS.has(tagLower(t))), { Tag: tag, Type: 0 }]
} else {
nextTags = [...tags, { Tag: tag, Type: 0 }]
}
const updated: Room = { ...room, Tags: tags }
const updated: Room = { ...room, Tags: nextTags }
await db
.prepare('UPDATE rooms SET data = ?2 WHERE room_id = ?1')
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, JSON.stringify(updated))
.run()
return updated
@@ -201,7 +222,7 @@ export async function saveSubRoomData(
if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage
await db
.prepare('UPDATE rooms SET data = ?2 WHERE room_id = ?1')
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, JSON.stringify(room))
.run()
return room
@@ -217,7 +238,7 @@ const parseAll = (rows: RoomRow[]): Room[] => rows.map((r) => JSON.parse(r.data)
/** Look up a single room by its RoomId. */
export async function getRoomById(db: D1Database, roomId: number): Promise<Room | null> {
return parseOne(
await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first<RoomRow>()
await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first<RoomRow>()
)
}
@@ -225,7 +246,7 @@ export async function getRoomById(db: D1Database, roomId: number): Promise<Room
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
return parseOne(
await db
.prepare('SELECT data FROM rooms WHERE name_lower = ?1')
.prepare('SELECT data FROM room WHERE name_lower = ?1')
.bind(name.toLowerCase())
.first<RoomRow>()
)
@@ -236,7 +257,7 @@ export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db
.prepare(`SELECT data FROM rooms WHERE room_id IN (${placeholders})`)
.prepare(`SELECT data FROM room WHERE room_id IN (${placeholders})`)
.bind(...ids)
.all<RoomRow>()
return parseAll(results)
@@ -245,7 +266,7 @@ export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room
/** All rooms created by an account (e.g. their dorm). */
export async function getRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
const { results } = await db
.prepare('SELECT data FROM rooms WHERE creator_account_id = ?1')
.prepare('SELECT data FROM room WHERE creator_account_id = ?1')
.bind(accountId)
.all<RoomRow>()
return parseAll(results)
@@ -277,7 +298,7 @@ export async function getFavoritedRooms(
.prepare(
`SELECT r.data AS data
FROM interaction i
JOIN rooms r ON r.room_id = i.room_id
JOIN room r ON r.room_id = i.room_id
WHERE i.player_id = ?1 AND i.favorited = 1
ORDER BY i.last_visited_at DESC`
)
@@ -302,7 +323,7 @@ export async function getVisitedRooms(
.prepare(
`SELECT r.data AS data
FROM interaction i
JOIN rooms r ON r.room_id = i.room_id
JOIN room r ON r.room_id = i.room_id
WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL
ORDER BY i.last_visited_at DESC`
)
@@ -459,7 +480,7 @@ export async function searchRooms(
if (q === '') return { Results: [], TotalResults: 0 }
const terms = q.split(/[\s+]+/).filter(Boolean)
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1)
for (const term of terms) {
@@ -496,7 +517,7 @@ export async function getHotRooms(
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
let rooms = parseAll(results).filter(
(r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true
)
@@ -525,7 +546,7 @@ export async function getRecommendedRooms(
skip: number,
take: number
): Promise<Room[]> {
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
return parseAll(results)
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
@@ -559,7 +580,7 @@ export interface FeaturedRoomGroup {
* Small dataset, so done in memory.
*/
export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> {
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const rooms = parseAll(results).filter(
(r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true
)
@@ -606,7 +627,7 @@ export async function getSimilarRooms(
const targetTags = new Set(roomTags(target))
if (targetTags.size === 0) return empty
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
@@ -639,7 +660,7 @@ export async function getSimilarRooms(
* array. Small dataset, so done in memory.
*/
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const base = new Set(['base'])
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
return parseAll(results)
+35 -35
View File
@@ -5,7 +5,6 @@ import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from './jwt'
import {
addRoomTag,
cloneRoom,
findSubRoom,
getBaseRooms,
@@ -30,6 +29,7 @@ import {
setRoomName,
toggleCheer,
toggleFavorite,
toggleRoomTag,
} from './rooms-db'
import type { Context } from 'hono'
@@ -209,18 +209,29 @@ function roomResult(
})
}
/** Client envelope for room clone results: `{ success, error, value }`. */
function cloneResult(c: Context<App>, value: unknown, error = '') {
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
function roomEnvelope(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). */
/** Rooms created/owned by the authed caller (shared by the createdby 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))
}
/**
* The caller's owned rooms, excluding their dorm. The dorm is auto-provisioned,
* not a room the player made, so it doesn't belong in the "rooms you own" list.
*/
async function ownedRoomsExcludingDorm(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const rooms = await getRoomsByCreator(c.env.DB, accountId)
return c.json(rooms.filter((r) => r.IsDorm !== true))
}
const app = new Hono<App>()
.use(
'*',
@@ -316,10 +327,11 @@ const app = new Hono<App>()
return c.json(room ? [room] : [])
})
// Rooms created/owned by the caller (their dorm). The client calls all three.
// Auth-gated — no token is a 401, never account 1.
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
// the `createdby` variants return everything the account created.
.get('/roomserver/rooms/createdby/me', ownedRooms)
.get('/rooms/ownedby/me', ownedRooms)
.get('/rooms/ownedby/me', ownedRoomsExcludingDorm)
.get('/rooms/createdby/me', ownedRooms)
// Public: the rooms a given account owns that are publicly viewable. No auth —
@@ -423,9 +435,9 @@ const app = new Hono<App>()
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 (name === '') return roomEnvelope(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!')
return roomEnvelope(c, null, 'A room with that name already exists!')
}
const room = await cloneRoom(
c.env.DB,
@@ -433,8 +445,8 @@ const app = new Hono<App>()
name,
accountId
)
if (!room) return cloneResult(c, null, "You can't clone this room!")
return cloneResult(c, room)
if (!room) return roomEnvelope(c, null, "You can't clone this room!")
return roomEnvelope(c, room)
})
// Update a room's description. Auth-gated (401) and owner-only. Business results
@@ -515,41 +527,29 @@ const app = new Hono<App>()
return roomResult(c, { Success: true })
})
// Add a tag to a room. Auth-gated (401) and owner-only. Body is the `tag` form
// field; the tag is added as a user tag (Type 0), deduped case-insensitively.
// Business results use the `{ Success, Value, ErrorId, Error }` envelope at 200.
// Toggle a tag on a room. Auth-gated (401) and owner-only. Body is the `tag`
// form field. There's no delete/patch endpoint, so this call toggles: it adds
// the tag (Type 0) if absent and removes it if present. The "main" tags
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
// others. Returns the `{ success, error, value }` envelope with the updated
// room as `value`; business failures are 200 with success:false.
.put('/rooms/:roomId{[0-9]+}/tags', 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) return roomEnvelope(c, null, '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!',
})
return roomEnvelope(c, null, 'You are not the owner of this room!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
if (tag === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidTag',
Error: 'You must provide a tag!',
})
}
await addRoomTag(c.env.DB, roomId, room, tag)
return roomResult(c, { Success: true })
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
return roomEnvelope(c, updated)
})
// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
+47 -31
View File
@@ -49,7 +49,7 @@ beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r))))
})
@@ -111,11 +111,13 @@ describe('rooms endpoints', () => {
// No token → 401, no stub-account fallback (would otherwise leak account 1).
const noAuth = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`)
expect(noAuth.status).toBe(401)
// Account 1 owns all the seeded rooms.
// Account 1 owns all the seeded rooms, but the dorm is excluded here.
const mine = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('1') })
).json()) as unknown[]
expect(mine.length).toBe(importRooms.length)
).json()) as Array<{ RoomId: number; IsDorm?: boolean }>
expect(mine.length).toBe(importRooms.filter((r) => r.IsDorm !== true).length)
// The dorm (RoomId 1) is auto-provisioned, so it never appears.
expect(mine.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
// A different account owns none of them.
const other = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('999') })
@@ -659,40 +661,54 @@ describe('rooms endpoints', () => {
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
})
it('PUT /rooms/:id/tags is auth-gated, owner-only, dedupes, and persists', async () => {
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
// The lowercase `{ success, error, value }` envelope this endpoint returns.
type TagResult = { success: boolean; error: string; value: { Tags?: Array<{ Tag: string }> } | null }
const envOf = async (res: Response) => (await res.json()) as TagResult
const tagsIn = (r: TagResult) => (r.value?.Tags ?? []).map((t) => t.Tag)
// No token → 401.
expect((await putForm('/rooms/2/tags', { tag: 'quest' })).status).toBe(401)
// Not the owner → NotOwner envelope.
expect(await bodyOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '999'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
// Not the owner → failure envelope.
expect(await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '999'))).toMatchObject({
success: false,
error: 'You are not the owner of this room!',
})
// Unknown room → DoesntExist.
expect(await bodyOf(await putForm('/rooms/99999/tags', { tag: 'quest' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.DoesntExist',
// Unknown room → failure envelope.
expect(await envOf(await putForm('/rooms/99999/tags', { tag: 'quest' }, '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
// Empty tag → InvalidTag.
expect(await bodyOf(await putForm('/rooms/2/tags', { tag: ' ' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.InvalidTag',
// Empty tag → failure envelope.
expect(await envOf(await putForm('/rooms/2/tags', { tag: ' ' }, '1'))).toMatchObject({
success: false,
error: 'You must provide a tag!',
})
// Owner adds a tag → it persists as a Type-0 user tag.
expect(await bodyOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1'))).toMatchObject({
Success: true,
})
const tagsOf = async () =>
(
(await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Tags: Array<{ Tag: string; Type: number }>
}
).Tags
expect(await tagsOf()).toContainEqual({ Tag: 'quest', Type: 0 })
// Owner adds a non-main tag → success envelope carries the updated room.
const added = await envOf(await putForm('/rooms/2/tags', { tag: 'spooky' }, '1'))
expect(added).toMatchObject({ success: true, error: '' })
expect(tagsIn(added)).toContain('spooky')
// Adding the same tag again (different case) is a no-op — no duplicate.
await putForm('/rooms/2/tags', { tag: 'QUEST' }, '1')
expect((await tagsOf()).filter((t) => t.Tag.toLowerCase() === 'quest')).toHaveLength(1)
// The same call again toggles it back off (no delete endpoint).
const removed = await envOf(await putForm('/rooms/2/tags', { tag: 'SPOOKY' }, '1'))
expect(tagsIn(removed)).not.toContain('spooky')
// Main tags are radio buttons: setting one clears any other main tag, but
// leaves non-main tags alone.
await putForm('/rooms/2/tags', { tag: 'campfire' }, '1') // non-main, stays put
const pvp = await envOf(await putForm('/rooms/2/tags', { tag: 'pvp' }, '1'))
expect(tagsIn(pvp)).toEqual(expect.arrayContaining(['pvp', 'campfire']))
const quest = await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1'))
expect(tagsIn(quest)).toContain('quest')
expect(tagsIn(quest)).not.toContain('pvp') // the previous main tag was cleared
expect(tagsIn(quest)).toContain('campfire') // non-main tag untouched
// Toggling the current main tag off just removes it (no other change).
const off = await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1'))
expect(tagsIn(off)).not.toContain('quest')
expect(tagsIn(off)).toContain('campfire')
})
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {