From 631fefcfdaeebd06c1d9bea97805167f86b3120c Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 6 Jul 2026 00:05:05 -0400 Subject: [PATCH] tags, dorms --- apps/rooms/src/rooms-db.ts | 23 +++++++++++++ apps/rooms/src/rooms.app.ts | 38 +++++++++++++++++++++ apps/rooms/src/test/integration/api.test.ts | 34 ++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index 59d8ebb..03f0d1b 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -125,6 +125,29 @@ export async function setRoomImage(db: D1Database, roomId: number, imageName: st .run() } +/** + * Add a user tag (`Type: 0`) to a room's `Tags`, skipping it when already present + * (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( + db: D1Database, + roomId: number, + room: Room, + tag: string +): Promise { + const tags = Array.isArray(room.Tags) ? (room.Tags as Array>) : [] + if (!tags.some((t) => String(t?.Tag).toLowerCase() === tag.toLowerCase())) { + tags.push({ Tag: tag, Type: 0 }) + } + const updated: Room = { ...room, Tags: tags } + await db + .prepare('UPDATE rooms SET data = ?2 WHERE room_id = ?1') + .bind(roomId, JSON.stringify(updated)) + .run() + return updated +} + /** Find a subroom (by SubRoomId) inside a room's `SubRooms` array, or undefined. */ export function findSubRoom(room: Room, subRoomId: number): Record | undefined { const subRooms = Array.isArray(room.SubRooms) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 111d9fa..59679fc 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -5,6 +5,7 @@ import { logger, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from './jwt' import { + addRoomTag, cloneRoom, findSubRoom, getBaseRooms, @@ -503,6 +504,43 @@ const app = new Hono() 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. + .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.CreatorAccountId !== accountId) { + return roomResult(c, { + Success: false, + ErrorId: 'Rooms.NotOwner', + Error: 'You are not the owner of this room!', + }) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + 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 }) + }) + // Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName` // form field (a key from the storage/image upload). Business results use the // `{ Success, Value, ErrorId, Error }` envelope at HTTP 200. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 83c47cb..679cef1 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -640,6 +640,40 @@ 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 () => { + // 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', + }) + // Unknown room → DoesntExist. + expect(await bodyOf(await putForm('/rooms/99999/tags', { tag: 'quest' }, '1'))).toMatchObject({ + Success: false, + ErrorId: 'Rooms.DoesntExist', + }) + // Empty tag → InvalidTag. + expect(await bodyOf(await putForm('/rooms/2/tags', { tag: ' ' }, '1'))).toMatchObject({ + Success: false, + ErrorId: 'Rooms.InvalidTag', + }) + + // 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 }) + + // 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) + }) + it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => { // No token → 401 (auth gate). expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)