mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
misc rooms endpoints, friends
This commit is contained in:
@@ -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 []
|
||||
|
||||
@@ -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: '' })
|
||||
|
||||
@@ -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);
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: '.*',
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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([]))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user