moving some more things to domains

This commit is contained in:
Devin Zuczek
2026-07-09 22:28:24 -04:00
parent 879b5d5905
commit 68d77ee4a4
11 changed files with 433 additions and 228 deletions
-208
View File
@@ -1,208 +0,0 @@
/**
* Room instances — live sessions of a room. Stored with the same JSON-blob pattern
* as the rooms/accounts tables: the full instance is a JSON blob in `data`, and
* every field is a SQLite generated (virtual) column extracted from it (snake_case
* per the C# `[Column]` names). `id` is a sequential key held in the blob.
*
* The `rooms` worker owns the schema (migrations/0004_room_instance.sql). The match
* worker finds/creates instances here — keep this in sync. Columns marked
* `[JsonIgnore]` in the C# (owner_account_id, data_blob, allow_new_users,
* join_disabled) live in the blob but are dropped from the client DTO (`toDto`).
*/
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_instance (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL,
owner_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ownerAccountId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomId')) VIRTUAL,
sub_room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.subRoomId')) VIRTUAL,
location TEXT GENERATED ALWAYS AS (json_extract(data, '$.location')) VIRTUAL,
data_blob TEXT GENERATED ALWAYS AS (json_extract(data, '$.dataBlob')) VIRTUAL,
event_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.eventId')) VIRTUAL,
photon_region_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRegionId')) VIRTUAL,
photon_room_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRoomId')) VIRTUAL,
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.name')) VIRTUAL,
max_capacity INTEGER GENERATED ALWAYS AS (json_extract(data, '$.maxCapacity')) VIRTUAL,
is_full INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isFull')) VIRTUAL,
is_private INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isPrivate')) VIRTUAL,
is_in_progress INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInProgress')) VIRTUAL,
room_code TEXT GENERATED ALWAYS AS (json_extract(data, '$.roomCode')) VIRTUAL,
room_instance_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceType')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.clubId')) VIRTUAL,
encrypt_voice_chat INTEGER GENERATED ALWAYS AS (json_extract(data, '$.EncryptVoiceChat')) VIRTUAL,
matchmaking_policy INTEGER GENERATED ALWAYS AS (json_extract(data, '$.matchmakingPolicy')) VIRTUAL,
allow_new_users INTEGER GENERATED ALWAYS AS (json_extract(data, '$.allowNewUsers')) VIRTUAL,
join_disabled INTEGER GENERATED ALWAYS AS (json_extract(data, '$.joinDisabled')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_id ON room_instance (id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_photon_room_id ON room_instance (photon_room_id)`,
`CREATE INDEX IF NOT EXISTS idx_room_instance_room_id ON room_instance (room_id)`,
]
/** Client-facing RoomInstance JSON (JsonPropertyName keys; JsonIgnore omitted). */
export interface RoomInstanceDto {
roomInstanceId: number
roomId: number
subRoomId: number
location: string
eventId: number
photonRegionId: string
photonRoomId: string
name: string
maxCapacity: number
isFull: boolean
isPrivate: boolean
isInProgress: boolean
roomCode: string
roomInstanceType: number
clubId: number
// PascalCase JSON key, per the C# `[JsonPropertyName("EncryptVoiceChat")]`.
EncryptVoiceChat: boolean
matchmakingPolicy: number
createdAt: string
}
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
interface StoredRoomInstance extends RoomInstanceDto {
ownerAccountId: number
dataBlob: string
allowNewUsers: boolean
joinDisabled: boolean
}
/** Fields for a new instance; `roomInstanceId` and `createdAt` are assigned here. */
export interface NewRoomInstance {
ownerAccountId: number
roomId: number
photonRoomId: string
subRoomId?: number
location?: string
dataBlob?: string
eventId?: number
photonRegionId?: string
name?: string
maxCapacity?: number
isFull?: boolean
isPrivate?: boolean
isInProgress?: boolean
roomCode?: string
roomInstanceType?: number
clubId?: number
encryptVoiceChat?: boolean
matchmakingPolicy?: number
allowNewUsers?: boolean
joinDisabled?: boolean
}
/** Project a stored instance to the client DTO (JsonIgnore fields dropped). */
function toDto(s: StoredRoomInstance): RoomInstanceDto {
return {
roomInstanceId: s.roomInstanceId,
roomId: s.roomId,
subRoomId: s.subRoomId,
location: s.location,
eventId: s.eventId,
photonRegionId: s.photonRegionId,
photonRoomId: s.photonRoomId,
name: s.name,
maxCapacity: s.maxCapacity,
isFull: s.isFull,
isPrivate: s.isPrivate,
isInProgress: s.isInProgress,
roomCode: s.roomCode,
roomInstanceType: s.roomInstanceType,
clubId: s.clubId,
EncryptVoiceChat: s.EncryptVoiceChat,
matchmakingPolicy: s.matchmakingPolicy,
createdAt: s.createdAt,
}
}
const parse = (data: string): StoredRoomInstance => JSON.parse(data) as StoredRoomInstance
/**
* Ids start high (above 1_000_000) so an instance id never collides with the
* dorm's fixed roomInstanceId of 1 — the client keys room transitions off the id,
* so a room instance that returned 1 would look like "still in the dorm".
*/
const ID_BASE = 1_000_000
/** Insert a new room instance, returning it as a client DTO. */
export async function createRoomInstance(
db: D1Database,
input: NewRoomInstance
): Promise<RoomInstanceDto> {
const idRow = await db
.prepare(`SELECT COALESCE(MAX(id), ${ID_BASE}) + 1 AS next FROM room_instance`)
.first<{ next: number }>()
const stored: StoredRoomInstance = {
roomInstanceId: idRow?.next ?? ID_BASE + 1,
ownerAccountId: input.ownerAccountId,
roomId: input.roomId,
subRoomId: input.subRoomId ?? 0,
location: input.location ?? '',
dataBlob: input.dataBlob ?? '',
eventId: input.eventId ?? 0,
photonRegionId: input.photonRegionId ?? 'us',
photonRoomId: input.photonRoomId,
name: input.name ?? '',
maxCapacity: input.maxCapacity ?? 0,
isFull: input.isFull ?? false,
isPrivate: input.isPrivate ?? false,
isInProgress: input.isInProgress ?? false,
roomCode: input.roomCode ?? '',
roomInstanceType: input.roomInstanceType ?? 0,
clubId: input.clubId ?? 0,
EncryptVoiceChat: input.encryptVoiceChat ?? false,
matchmakingPolicy: input.matchmakingPolicy ?? 0,
allowNewUsers: input.allowNewUsers ?? true,
joinDisabled: input.joinDisabled ?? false,
createdAt: new Date().toISOString(),
}
await db.prepare('INSERT INTO room_instance (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
return toDto(stored)
}
/** Look up a room instance by its id (roomInstanceId). */
export async function getRoomInstance(db: D1Database, id: number): Promise<RoomInstanceDto | null> {
const row = await db
.prepare('SELECT data FROM room_instance WHERE id = ?1')
.bind(id)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/**
* The oldest joinable public instance of a room (not private, not full, joins
* enabled), or null when there's none to join. Used by matchmaking to reuse an
* existing instance before creating a new one.
*/
export async function getJoinableInstance(
db: D1Database,
roomId: number
): Promise<RoomInstanceDto | null> {
const row = await db
.prepare(
`SELECT data FROM room_instance
WHERE room_id = ?1 AND is_private = 0 AND is_full = 0 AND join_disabled = 0
ORDER BY id LIMIT 1`
)
.bind(roomId)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/** All instances of a given room. */
export async function getRoomInstancesByRoom(
db: D1Database,
roomId: number
): Promise<RoomInstanceDto[]> {
const { results } = await db
.prepare('SELECT data FROM room_instance WHERE room_id = ?1')
.bind(roomId)
.all<{ data: string }>()
return results.map((r) => toDto(parse(r.data)))
}
+94
View File
@@ -3,6 +3,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
cloneRoom,
cloneSubRoom,
findSubRoom,
getBaseRooms,
getFavoritedRooms,
@@ -17,6 +18,7 @@ import {
getRoomsByIds,
getSimilarRooms,
getVisitedRooms,
modifySubRoom,
removeCheer,
removeFavorite,
saveSubRoomData,
@@ -664,6 +666,98 @@ const app = new Hono<App>()
return c.json(findSubRoom(updated, subRoomId) ?? {})
})
// Modify a subroom's settings (Name/Accessibility/MaxPlayers) from the form body.
// Auth-gated (401) and owner-only — only the room creator may change its subrooms.
// Notifies the owner (RoomUpdate) and returns the `{ Success, Value, ErrorId, Error }`
// envelope at HTTP 200, matching the other owner-gated room mutations.
.put('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
if (!findSubRoom(room, subRoomId)) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This subroom does not exist!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidName',
Error: 'You must enter a name for your room!',
})
}
const accessibility =
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
name,
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
})
if (!updated) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This subroom does not exist!',
})
}
await pushRoomUpdate(c, accountId, updated)
return roomResult(c, { Success: true })
})
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
// returns the `{ success, error, value }` envelope with the new subroom as `value`,
// mirroring the room-level `/clone`. Response shape is a best guess (the real
// client's expected body is unknown).
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) {
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
if (room.CreatorAccountId !== accountId) {
return roomEnvelope(c, null, 'You are not the owner of this room!')
}
const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.subRoom)
})
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
// unknown/untagged.
+72 -5
View File
@@ -3,14 +3,14 @@ import { beforeAll, describe, expect, it } from 'vitest'
import '../../rooms.app'
import { ROOM_SCHEMA_DDL } from '@repo/domain'
import importRooms from '../../../static/ImportRooms.json'
import {
createRoomInstance,
getRoomInstance,
SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL,
} from '../../room-instance-db'
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
} from '@repo/domain'
import importRooms from '../../../static/ImportRooms.json'
import type { Env } from '../../context'
@@ -933,4 +933,71 @@ describe('rooms endpoints', () => {
).json()) as unknown[]
expect(visited).toEqual([])
})
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
const fields = { name: 'My Cool Subroom', accessibility: '1', maxPlayers: '20' }
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → NotOwner.
expect(await bodyOf(await putForm('/rooms/2/subrooms/2/modify', fields, '999'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
// Unknown room → DoesntExist.
expect(
await bodyOf(await putForm('/rooms/99999/subrooms/2/modify', fields, '1'))
).toMatchObject({ Success: false, ErrorId: 'Rooms.DoesntExist' })
// Unknown subroom → DoesntExist.
expect(await bodyOf(await putForm('/rooms/2/subrooms/9999/modify', fields, '1'))).toMatchObject(
{ Success: false, ErrorId: 'Rooms.DoesntExist' }
)
// Empty name → InvalidName.
expect(
await bodyOf(await putForm('/rooms/2/subrooms/2/modify', { ...fields, name: ' ' }, '1'))
).toMatchObject({ Success: false, ErrorId: 'Rooms.InvalidName' })
// Owner updates the subroom → Success, and it persists on the subroom descriptor.
const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
Name: string
Accessibility: number
MaxPlayers: number
}
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
})
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
method: 'POST',
headers: sub ? await bearer(sub) : {},
})
const envelope = async (res: Response) =>
(await res.json()) as {
success: boolean
error: string
value: { SubRoomId: number; CreatorAccountId: number } | null
}
// No token → 401.
expect((await clone(2, 2)).status).toBe(401)
// Not the owner → success:false envelope.
expect((await envelope(await clone(2, 2, '999'))).success).toBe(false)
// Unknown subroom → success:false envelope.
expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false)
// Owner clones → success, a fresh SubRoomId owned by the caller, fetchable on the room.
const res = await clone(2, 2, '1')
expect(res.status).toBe(200)
const body = await envelope(res)
expect(body.success).toBe(true)
expect(body.value?.SubRoomId).not.toBe(2)
expect(body.value?.CreatorAccountId).toBe(1)
const fetched = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${body.value?.SubRoomId}/data`)
).json()) as { SubRoomId: number }
expect(fetched.SubRoomId).toBe(body.value?.SubRoomId)
})
})