From 68d77ee4a474fb5fde5acce4c5aff054cb2ab4a6 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 9 Jul 2026 22:28:24 -0400 Subject: [PATCH] moving some more things to domains --- apps/api/src/relationships-db.ts | 49 +++++ apps/api/src/routes/social.ts | 21 ++ apps/api/src/test/integration/api.test.ts | 44 ++++ apps/match/src/match.app.ts | 27 ++- apps/match/src/test/integration/api.test.ts | 50 ++++- apps/rooms/src/room-instance-db.ts | 208 ------------------ apps/rooms/src/rooms.app.ts | 94 ++++++++ apps/rooms/src/test/integration/api.test.ts | 77 ++++++- packages/domain/src/index.ts | 1 + .../domain}/src/room-instance-db.ts | 13 +- packages/domain/src/rooms-db.ts | 77 +++++++ 11 files changed, 433 insertions(+), 228 deletions(-) delete mode 100644 apps/rooms/src/room-instance-db.ts rename {apps/match => packages/domain}/src/room-instance-db.ts (94%) diff --git a/apps/api/src/relationships-db.ts b/apps/api/src/relationships-db.ts index 6d46bbc..9fa3f79 100644 --- a/apps/api/src/relationships-db.ts +++ b/apps/api/src/relationships-db.ts @@ -263,3 +263,52 @@ export async function removeFriend(db: D1Database, a: number, b: number): Promis .bind(a, b) .run() } + +/** A per-player relationship flag — each is stored on the player's own side of the row. */ +export type RelationshipFlag = 'favorited' | 'ignored' | 'muted' + +/** + * Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their + * relationship with `otherId`. These flags are stored per player, so the write + * targets the caller's OWN side of the row — `requester_*` when the caller + * initiated the pair, `target_*` otherwise. When the pair has no relationship yet + * (you can ignore/mute someone you aren't friends with) a fresh `None` row is + * created with the caller as requester. Returns the relationship from `playerId`'s + * point of view. The `flag`/side names are a fixed union, so interpolating them + * into the SQL is safe (same pattern as the room interaction toggles). + */ +export async function setRelationshipFlag( + db: D1Database, + playerId: number, + otherId: number, + flag: RelationshipFlag, + value: boolean +): Promise { + const existing = await findPair(db, playerId, otherId) + const v = value ? 1 : 0 + if (!existing) { + // New row: the caller is the requester, so the flag lives on the requester side. + await db + .prepare( + `INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag}) + VALUES (?1, ?2, ?3, ?4)` + ) + .bind(playerId, otherId, RelationshipType.None, v) + .run() + } else { + // Update whichever side the caller is on, leaving the other player's flag alone. + const side = existing.requester_id === playerId ? 'requester' : 'target' + await db + .prepare( + `UPDATE relationship SET ${side}_${flag} = ?3 + WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)` + ) + .bind(playerId, otherId, v) + .run() + } + + const updated = await findPair(db, playerId, otherId) + return updated + ? toResponse(updated, playerId) + : { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 } +} diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index e618ea5..7d9bdd3 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -6,6 +6,7 @@ import { getRelationshipsForPlayer, removeFriend, sendFriendRequest, + setRelationshipFlag, } from '../relationships-db' import { authedId, unauthorized } from '../http' @@ -88,5 +89,25 @@ export const socialRoutes = new Hono({ strict: false }) return c.json(await addFriend(c.env.DB, id, target)) }) + // Ignore / mute another player (target arrives as `PlayerId` in the POST body). + // These set a per-player flag on the *caller's* side of the relationship row, + // creating a bare (None) row when the pair aren't otherwise related — so you can + // ignore/mute someone you've never friended. Auth-gated. Returns the resulting + // relationship from the caller's point of view. + .on(['GET', 'POST'], '/api/relationships/v1/ignore', 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 setRelationshipFlag(c.env.DB, id, target, 'ignored', true)) + }) + .on(['GET', 'POST'], '/api/relationships/v1/mute', 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 setRelationshipFlag(c.env.DB, id, target, 'muted', true)) + }) + .get('/api/messages/v2/get', (c) => c.json([])) .get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([])) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 00b4c16..ec1bd34 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -649,6 +649,8 @@ describe('relationships', () => { '/api/relationships/v2/acceptfriendrequest', '/api/relationships/v2/removefriend', '/api/relationships/v2/addfriend', + '/api/relationships/v1/ignore', + '/api/relationships/v1/mute', ]) { const res = await exports.default.fetch(`${ORIGIN}${path}?id=1`) expect(res.status).toBe(401) @@ -693,4 +695,46 @@ describe('relationships', () => { test('a self-targeted request is rejected', async () => { expect((await mutate('/api/relationships/v2/sendfriendrequest', '530', 530)).status).toBe(400) }) + + test('v1 ignore/mute set the caller’s own side of the relationship', async () => { + type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number } + // POST the real client shape: form body `PlayerId=`. + const flag = async (path: string, sub: string, playerId: number) => + (await ( + await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `PlayerId=${playerId}`, + }) + ).json()) as FullRel + + // 700 ignores 701 with no prior relationship → a bare None row, the caller's side flagged. + expect(await flag('/api/relationships/v1/ignore', '700', 701)).toMatchObject({ + PlayerID: 701, + RelationshipType: 0, + Ignored: 1, + Muted: 0, + }) + // 700 then mutes 701 → same row, mute added, the earlier ignore preserved. + expect(await flag('/api/relationships/v1/mute', '700', 701)).toMatchObject({ + PlayerID: 701, + Ignored: 1, + Muted: 1, + }) + + // The tricky case: the caller is the row's TARGET. 710 sends 711 a request + // (710 = requester); 711 ignoring 710 must flag the target side, not the requester's. + await mutate('/api/relationships/v2/sendfriendrequest', '710', 711) + expect(await flag('/api/relationships/v1/ignore', '711', 710)).toMatchObject({ + PlayerID: 710, + RelationshipType: 2, // 711 sees 710's request as Received + Ignored: 1, + }) + // 710's own side is untouched — the requester never ignored anyone. + const view710 = (await relationships('710')) as unknown as FullRel[] + expect(view710).toEqual([expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 })]) + }) }) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 9674286..90fa75d 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -2,21 +2,18 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { + createRoomInstance, + getJoinableInstance, getOrCreateDormRoom, getRoomById, getRoomByName, + getRoomInstancesByRoom, RoomInstanceType, + setRoomInstanceInProgress, } from '@repo/domain' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' -import { - createRoomInstance, - getJoinableInstance, - getRoomInstancesByRoom, - setRoomInstanceInProgress, -} from './room-instance-db' - import type { Room } from '@repo/domain' import type { Context } from 'hono' import type { App } from './context' @@ -516,6 +513,22 @@ const app = new Hono() return c.body(null, 200) }) + // The room's live instances — the owner's view of active sessions of their room. + // Auth-gated (401) and owner-only (403): the caller must be the room's creator. + // Unknown room → 404. Returns the bare RoomInstance DTO array (empty when the + // room has no live instances). + .get('/room/:roomId{[0-9]+}/instances', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return c.body(null, 404) + if (room.CreatorAccountId !== id) return c.body(null, 403) + + return c.json(await getRoomInstancesByRoom(c.env.DB, roomId)) + }) + // Rooms flagged as needing a developer/moderator to spawn in. No such queue // yet → empty list. .get('/rooms/requiring/developer', (c) => c.json([])) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 132d95d..c83ada7 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -2,9 +2,9 @@ import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' -import '../../match.app' +import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain' -import { SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL } from '../../room-instance-db' +import '../../match.app' import type { Env } from '../../context' @@ -32,6 +32,14 @@ const TEST_ROOMS = [ Accessibility: 1, SubRooms: [{ SubRoomId: 2, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 12 }], }, + { + RoomId: 3, + Name: 'TestersRoom', + IsDorm: false, + Accessibility: 1, + CreatorAccountId: 42, + SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }], + }, ] beforeAll(async () => { @@ -478,4 +486,42 @@ describe('auth-gated endpoints', () => { const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }> expect(players[0]).toMatchObject({ playerId: 55, isOnline: true }) }) + + test('GET /room/:id/instances is auth-gated, owner-only, and lists the room’s instances', async () => { + // No token → 401. + expect( + (await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status + ).toBe(401) + + // Not the owner (room 3 is owned by account 42) → 403. + expect( + ( + await exports.default.fetch(`${ORIGIN}/room/3/instances`, { + headers: await bearer('999'), + }) + ).status + ).toBe(403) + + // Unknown room → 404. + expect( + ( + await exports.default.fetch(`${ORIGIN}/room/99999/instances`, { + headers: await bearer('42'), + }) + ).status + ).toBe(404) + + // Matchmaking into room 3 creates an instance the owner can then see. + await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, { + method: 'POST', + headers: await bearer('42'), + }) + const res = await exports.default.fetch(`${ORIGIN}/room/3/instances`, { + headers: await bearer('42'), + }) + expect(res.status).toBe(200) + const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }> + expect(instances.length).toBeGreaterThanOrEqual(1) + expect(instances.every((i) => i.roomId === 3)).toBe(true) + }) }) diff --git a/apps/rooms/src/room-instance-db.ts b/apps/rooms/src/room-instance-db.ts deleted file mode 100644 index 90119cd..0000000 --- a/apps/rooms/src/room-instance-db.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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))) -} diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 6def465..0c12b33 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() 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 + 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. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index dd770b9..54e1ad3 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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) + }) }) diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 17f6a59..47097e5 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,3 +1,4 @@ export { RoomInstanceType, Accessibility, Role } from './enums' export * from './accounts-db' export * from './rooms-db' +export * from './room-instance-db' diff --git a/apps/match/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts similarity index 94% rename from apps/match/src/room-instance-db.ts rename to packages/domain/src/room-instance-db.ts index 3b1fc6a..ac92bb3 100644 --- a/apps/match/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -4,15 +4,16 @@ * 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. * - * Mirror of `apps/rooms/src/room-instance-db.ts` — the `rooms` worker owns the - * schema (migrations/0004_room_instance.sql); this worker finds/creates instances - * here at matchmake time, keeping this copy 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`). + * The `rooms` worker owns the schema (migrations/0004_room_instance.sql); the + * `match` worker finds/creates instances here at matchmake time. This module is the + * single source of truth for the helpers — both workers import it from + * `@repo/domain`. 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[] = [ +export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS room_instance ( data TEXT NOT NULL, id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL, diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 9bcf42c..71574a3 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -233,6 +233,83 @@ export async function saveSubRoomData( return room } +/** Fields from the client's subroom `modify` form (each applied only when supplied). */ +export interface ModifySubRoomInput { + name?: string + accessibility?: number + maxPlayers?: number +} + +/** + * Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers + * (the fields the client's subroom `modify` form carries). Only the supplied + * fields are changed; the whole room JSON is rewritten (subrooms live in it). + * Returns the updated room, or null when the room or subroom doesn't exist. + */ +export async function modifySubRoom( + db: D1Database, + roomId: number, + subRoomId: number, + input: ModifySubRoomInput +): Promise { + const room = await getRoomById(db, roomId) + if (!room) return null + const sub = findSubRoom(room, subRoomId) + if (!sub) return null + + if (input.name !== undefined) sub.Name = input.name + if (input.accessibility !== undefined) sub.Accessibility = input.accessibility + if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers + + await db + .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') + .bind(roomId, JSON.stringify(room)) + .run() + return room +} + +/** + * Clone an existing subroom into a new subroom of the same room, owned by + * `accountId`. The copy keeps the source's scene/settings (and its saved data + * blobs, so it loads identical content) but gets a fresh SubRoomId — the next + * integer above the room's current subrooms. Returns the updated room and the new + * subroom, or null when the room or source subroom doesn't exist. + */ +export async function cloneSubRoom( + db: D1Database, + roomId: number, + subRoomId: number, + accountId: number +): Promise<{ room: Room; subRoom: Record } | null> { + const room = await getRoomById(db, roomId) + if (!room) return null + const source = findSubRoom(room, subRoomId) + if (!source) return null + + const subRooms = Array.isArray(room.SubRooms) + ? (room.SubRooms as Array>) + : [] + const nextSubRoomId = + subRooms.reduce((max, s) => { + const id = typeof s.SubRoomId === 'number' ? s.SubRoomId : 0 + return id > max ? id : max + }, 0) + 1 + + const subRoom: Record = { + ...source, + SubRoomId: nextSubRoomId, + RoomId: room.RoomId, + CreatorAccountId: accountId, + } + + room.SubRooms = [...subRooms, subRoom] + await db + .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') + .bind(roomId, JSON.stringify(room)) + .run() + return { room, subRoom } +} + interface RoomRow { data: string }