diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 06ebb70..c0cb764 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -301,6 +301,13 @@ export const RoomDto = z.object({ .int() .nullable() .describe('The room’s published snapshot. Nothing takes snapshots here, so always null'), + FriendlyName: z + .string() + .describe('Display name. Nothing sets one apart from `Name` here, so it mirrors `Name`'), + CCU: z + .int() + .nullable() + .describe('Concurrent users. Nothing counts live population here, so always null'), RankingContext: z.unknown().nullable(), IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'), IsPlacePlay: z.boolean(), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index bd6b02d..ac04d5e 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -733,6 +733,10 @@ const app = new Hono() // Room search: `query` is space/`+`-separated terms — `#tag` matches room tags, // plain terms match the name. Public, non-dorm rooms only. Paginated via // skip/take. Returns `{ Results, TotalResults }`. + // + // `#community` is the one tag term that isn't a tag lookup: it is the browse chip's + // pseudo-tag reaching search, and means rooms a PLAYER made rather than the seeded + // first-party ones — the same filter `/rooms/hot?tag=community` applies. .get( '/rooms/search', describeRoute({ @@ -741,9 +745,14 @@ const app = new Hono() description: [ 'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the', 'room’s tags, a plain term matches its name. Public, non-dorm rooms only.', + '`#community` is a pseudo-tag no room carries — it narrows to rooms a player made', + '(anything the system Coach account didn’t create), like `/rooms/hot?tag=community`.', ].join(' '), parameters: [ - stringQuery('query', 'Search terms — `#tag` matches tags, plain terms match the name'), + stringQuery( + 'query', + 'Search terms — `#tag` matches tags, plain terms match the name, `#community` matches player-made rooms' + ), ...pageParams(30), ], responses: { 200: json(PagedRooms, 'The matching rooms') }, @@ -1093,13 +1102,15 @@ const app = new Hono() ownedRooms ) - // Rooms the caller CONTRIBUTES to — someone else's rooms that name them in `Roles` - // (Host, Moderator or CoOwner). Auth-scoped: `me` resolves from the bearer token, and - // there is no query string or body to read. + // Rooms the caller works on — the ones they CREATED plus anyone else's that name them in + // `Roles` (Host, Moderator or CoOwner). Auth-scoped: `me` resolves from the bearer token, + // and there is no query string or body to read. // - // Rooms the caller created are excluded: a room's `Roles` carries its creator too, so - // without that this would repeat `createdby/me` wholesale, and the client shows the two - // as separate lists. Like the other `*by/me` lists it answers a bare array of the + // Created rooms used to be excluded, on the grounds that a room's `Roles` names its + // creator too and the client shows "owned" and "contributed" separately. That left the + // list empty for every account that had only built its own rooms — most of them — so it + // now overlaps `createdby/me` rather than coming back empty. The dorm stays out, as it + // does on `ownedby/me`. Like the other `*by/me` lists it answers a bare array of the // canonical room DTO — no envelope, no paging wrapper — and doesn't filter on // accessibility, since a contributor is working on the room whether or not it's // published. @@ -1107,18 +1118,18 @@ const app = new Hono() '/rooms/contributedby/me', describeRoute({ tags: ['My rooms'], - summary: 'Rooms the caller contributes to', + summary: 'Rooms the caller owns or contributes to', description: [ - 'The rooms that name the caller in their `Roles` — Host, Moderator or CoOwner — as a', - 'bare array of rooms. Rooms the caller CREATED are excluded: those are', - '`ownedby/me`/`createdby/me`, and a room’s roles list its creator too, so including', - 'them would just repeat that list. Every role tier counts, not only the owner-level', - 'ones, and accessibility is not filtered: a contributor works on the room whether or', - 'not it is published.', + 'Every room the caller works on, as a bare array: the ones they CREATED plus the ones', + 'that name them in their `Roles` — Host, Moderator or CoOwner. Overlaps', + '`createdby/me` deliberately, so a client rendering one list sees everything; the', + 'dorm is excluded as it is on `ownedby/me`. Every role tier counts, not only the', + 'owner-level ones, and accessibility is not filtered: a contributor works on the room', + 'whether or not it is published.', ].join(' '), security: AUTHED, responses: { - 200: json(RoomDto.array(), 'The rooms the caller contributes to (empty when none)'), + 200: json(RoomDto.array(), 'The rooms the caller owns or contributes to (empty when none)'), 401: UNAUTHORIZED_RESPONSE, }, }), diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index f825733..d76320b 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -153,15 +153,18 @@ describe('rooms endpoints', () => { expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163') }) - // Neither is stored — the seed blobs predate both keys — so they are defaulted on read. - // The client's room DTO always carries them, and an ABSENT key is not the same as a - // zero/null one to its parser. - it('GET /rooms/:id carries BoostCount and CurrentSnapshotId', async () => { + // None of these are stored — the seed blobs predate the keys — so they are defaulted on + // read. The client's room DTO always carries them, and an ABSENT key is not the same as a + // zero/null one to its parser. `FriendlyName` is the one that can't be null: the client + // labels the room from it. + it('GET /rooms/:id carries BoostCount, CurrentSnapshotId, FriendlyName and CCU', async () => { const res = await SELF.fetch(`${ORIGIN}/rooms/1`) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body).toHaveProperty('BoostCount', 0) expect(body).toHaveProperty('CurrentSnapshotId', null) + expect(body).toHaveProperty('FriendlyName', body.Name) + expect(body).toHaveProperty('CCU', null) }) // Pinned whole: these are the numbers the client's publish UI counts against, and @@ -418,7 +421,7 @@ describe('rooms endpoints', () => { expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false) }) - it('GET /rooms/contributedby/me lists rooms the caller has a role in, not their own', async () => { + it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => { const seed = (data: Record) => env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run() @@ -445,7 +448,8 @@ describe('rooms endpoints', () => { SubRooms: [], Roles: [{ AccountId: 820, Role: 10 }], }) - // ...one they created themselves, whose Roles name them as Creator... + // ...one they created themselves, whose Roles name them as Creator (matched by BOTH + // halves of the query, so it must still appear exactly once)... await seed({ RoomId: 30403, Name: 'ContribOwn', @@ -454,6 +458,25 @@ describe('rooms endpoints', () => { SubRooms: [], Roles: [{ AccountId: 820, Role: 255 }], }) + // ...one they created that names nobody in Roles at all — the older rooms have no + // Roles key, and those reach the list on the creator half alone... + await seed({ + RoomId: 30406, + Name: 'ContribOwnNoRoles', + CreatorAccountId: 820, + Accessibility: 1, + SubRooms: [], + }) + // ...and their dorm, which stays out: auto-provisioned, not a room they made. + await seed({ + RoomId: 30407, + Name: "@player820's Dorm", + CreatorAccountId: 820, + IsDorm: true, + Accessibility: 2, + SubRooms: [], + Roles: [{ AccountId: 820, Role: 255 }], + }) // ...one they have nothing to do with, and one with no Roles key at all (the older // seeded rooms have none — json_each must drop them, not error). await seed({ @@ -473,9 +496,10 @@ describe('rooms endpoints', () => { const rooms = (await res.json()) as Array<{ RoomId: number; Name: string }> // A bare array of the canonical room DTO — no envelope, no paging wrapper. expect(Array.isArray(rooms)).toBe(true) - expect(rooms.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([30401, 30402]) - // The caller's OWN room is excluded, or this would just repeat createdby/me. - expect(rooms.some((r) => r.RoomId === 30403)).toBe(false) + expect(rooms.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([30401, 30402, 30403, 30406]) + // Their own rooms are IN — the list overlaps createdby/me rather than coming back + // empty for someone who only ever built their own — but the dorm is not. + expect(rooms.some((r) => r.RoomId === 30407)).toBe(false) expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) }) // A player who contributes to nothing gets an empty array, not a 404. @@ -489,7 +513,7 @@ describe('rooms endpoints', () => { // The DB is shared across this file, and these are the only player-made public rooms // in it — leaving them behind changes what the `new`/`community` room feeds serve. - await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30405').run() + await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run() }) it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => { @@ -612,6 +636,57 @@ describe('rooms endpoints', () => { expect(aliased.TotalResults).toBeGreaterThan(0) }) + it('GET /rooms/search?query=#community serves rooms the Coach account did not create', async () => { + // The browse chip's word reaches search as a tag term, but no room CARRIES a + // `community` tag — it is the same pseudo-tag `/rooms/hot?tag=community` applies, so it + // filters on who MADE the room. Every seeded room belongs to Coach (account 1), so it + // finds nothing until another account makes something. + type Page = { Results: Array<{ Name: string }>; TotalResults: number } + const search = async (query: string): Promise => + (await ( + await SELF.fetch(`${ORIGIN}/rooms/search?query=${encodeURIComponent(query)}&take=100`) + ).json()) as Page + + expect(await search('#community')).toEqual({ Results: [], TotalResults: 0 }) + + const seeded: number[] = [] + const seed = async (room: Record) => { + seeded.push(Number(room.RoomId)) + await seedRoomWithSubRooms(env.DB, { + Accessibility: 1, + IsDorm: false, + CreatorAccountId: 2, + ...room, + }) + } + + await seed({ RoomId: 9201, Name: 'CommunityHorrorHouse' }) + await seed({ RoomId: 9202, Name: 'CommunityArcade' }) + // Coach's own rooms stay out, and so do non-public ones as everywhere else in search. + await seed({ RoomId: 9203, Name: 'CommunityCoachRoom', CreatorAccountId: 1 }) + await seed({ RoomId: 9204, Name: 'CommunityUnlisted', Accessibility: 2 }) + + const found = await search('#community') + expect(found.Results.map((r) => r.Name)).toEqual(['CommunityHorrorHouse', 'CommunityArcade']) + expect(found.TotalResults).toBe(2) + + // The client sends the chip with a trailing space (`?query=%23community+`), which the + // term split has to swallow rather than search for an empty second term. + expect(await search('#community ')).toEqual(found) + + // It NARROWS the rest of the query rather than replacing it: a name term still applies, + // and so does a real tag, which is still the SQL lookup it always was. + expect((await search('#community arcade')).Results.map((r) => r.Name)).toEqual([ + 'CommunityArcade', + ]) + expect(await search('#community #rro')).toEqual({ Results: [], TotalResults: 0 }) + + // Leave the shared dataset as it was for the tests that follow. + const ids = seeded.join(',') + await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run() + await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run() + }) + it('GET /rooms/favoritedby/me returns a bare array of the caller favorited rooms (auth-scoped)', async () => { const headers = await bearer('777')