From 8e255b0129f9b137fb73d3d485a1d6d1d0a311fa Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 5 Jul 2026 17:07:28 -0400 Subject: [PATCH] more interaction endpoints --- apps/rooms/src/rooms-db.ts | 37 +++++++++++ apps/rooms/src/rooms.app.ts | 26 ++++++++ apps/rooms/src/test/integration/api.test.ts | 68 +++++++++++++++++++++ pnpm-lock.yaml | 31 ---------- 4 files changed, 131 insertions(+), 31 deletions(-) diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index ca0920f..957079a 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -273,6 +273,43 @@ export async function toggleFavorite( return toggleInteraction(db, playerId, roomId, 'favorited') } +/** + * Explicitly clear a single interaction flag on a room (the DELETE counterpart to + * the cheer/favorite toggles). Idempotent: only clears an existing interaction row + * and never creates one, so clearing a flag on a room the player never interacted + * with doesn't add a spurious visited/favorited entry. Returns the interaction. + */ +async function clearInteraction( + db: D1Database, + playerId: number, + roomId: number, + column: 'cheered' | 'favorited' +): Promise { + await db + .prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`) + .bind(playerId, roomId) + .run() + return getInteraction(db, playerId, roomId) +} + +/** Clear the player's cheer on a room (DELETE cheer), returning the interaction. */ +export async function removeCheer( + db: D1Database, + playerId: number, + roomId: number +): Promise { + return clearInteraction(db, playerId, roomId, 'cheered') +} + +/** Clear the player's favorite on a room (DELETE favorite), returning the interaction. */ +export async function removeFavorite( + db: D1Database, + playerId: number, + roomId: number +): Promise { + return clearInteraction(db, playerId, roomId, 'favorited') +} + /** * Search-tag aliases: a queried `#tag` also matches these stored tag names. * The client's pinned filters don't always match how rooms are tagged (e.g. it diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 7e03436..5423c3d 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -17,6 +17,8 @@ import { getRoomsByCreator, getRoomsByIds, getSimilarRooms, + removeCheer, + removeFavorite, getVisitedRooms, searchRooms, setRoomDescription, @@ -270,6 +272,18 @@ const app = new Hono() ) return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) }) + // Explicitly un-cheer a room (DELETE clears the cheer, vs the PUT toggle). + // Auth-gated; idempotent — un-cheering when there's no cheer is a no-op. + .delete('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const interaction = await removeCheer( + c.env.DB, + accountId, + Number.parseInt(c.req.param('roomId'), 10) + ) + return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) + }) .put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) @@ -280,6 +294,18 @@ const app = new Hono() ) return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) }) + // Explicitly un-favorite a room (DELETE clears the favorite, vs the PUT toggle). + // Auth-gated; idempotent — un-favoriting when there's no favorite is a no-op. + .delete('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const interaction = await removeFavorite( + c.env.DB, + accountId, + Number.parseInt(c.req.param('roomId'), 10) + ) + return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) + }) // Clone a room into a new one owned by the caller, using the `name` form field // (also accepted as a query param). Auth is required — no valid token is a 401, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 24f6ddb..037d8fa 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -620,4 +620,72 @@ describe('rooms endpoints', () => { ).json()) as Interaction expect(otherGet).toMatchObject({ Cheered: false, Favorited: false }) }) + + it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => { + type Interaction = { Cheered: boolean; Favorited: boolean } + const headers = await bearer('557') + const del = () => + SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'DELETE', headers }) + + // No token → 401. + expect( + (await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'DELETE' })).status + ).toBe(401) + + // Cheer + favorite on, then DELETE clears only the cheer (favorite untouched). + await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'PUT', headers }) + await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'PUT', headers }) + expect(await (await del()).json()).toMatchObject({ Cheered: false, Favorited: true }) + + // Idempotent — a second DELETE stays cleared. + expect(await (await del()).json()).toMatchObject({ Cheered: false, Favorited: true }) + + // Idempotent on a never-interacted room, and it doesn't create a visited row. + const fresh = await bearer('558') + const res = await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, { + method: 'DELETE', + headers: fresh, + }) + expect(await res.json()).toMatchObject({ Cheered: false, Favorited: false }) + const visited = (await ( + await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: fresh }) + ).json()) as unknown[] + expect(visited).toEqual([]) + }) + + it('DELETE /rooms/:id/interactionby/me/favorite clears the favorite (auth-gated, idempotent)', async () => { + const headers = await bearer('559') + const del = () => + SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'DELETE', headers }) + + // No token → 401. + expect( + (await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'DELETE' })).status + ).toBe(401) + + // Favorite + cheer on, then DELETE clears only the favorite (cheer untouched). + await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'PUT', headers }) + await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'PUT', headers }) + expect(await (await del()).json()).toMatchObject({ Cheered: true, Favorited: false }) + // It drops out of the caller's favorited list. + const favs = (await ( + await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me`, { headers }) + ).json()) as unknown[] + expect(favs).toEqual([]) + + // Idempotent — a second DELETE stays cleared. + expect(await (await del()).json()).toMatchObject({ Cheered: true, Favorited: false }) + + // Idempotent on a never-interacted room, without creating a visited row. + const fresh = await bearer('560') + const res = await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/favorite`, { + method: 'DELETE', + headers: fresh, + }) + expect(await res.json()).toMatchObject({ Cheered: false, Favorited: false }) + const visited = (await ( + await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: fresh }) + ).json()) as unknown[] + expect(visited).toEqual([]) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 560ff0e..8e21e9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,37 +268,6 @@ importers: specifier: 4.105.0 version: 4.105.0(@cloudflare/workers-types@4.20260630.1) - apps/datacollection: - dependencies: - '@repo/hono-helpers': - specifier: workspace:* - version: link:../../packages/hono-helpers - hono: - specifier: 4.12.27 - version: 4.12.27 - workers-tagged-logger: - specifier: 1.0.1 - version: 1.0.1 - devDependencies: - '@cloudflare/vitest-pool-workers': - specifier: 0.16.20 - version: 0.16.20(@cloudflare/workers-types@4.20260630.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@26.0.1)(vite@6.3.4(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.29.2)(tsx@4.22.4)(yaml@2.9.0))) - '@repo/tools': - specifier: workspace:* - version: link:../../packages/tools - '@repo/typescript-config': - specifier: workspace:* - version: link:../../packages/typescript-config - '@types/node': - specifier: 26.0.1 - version: 26.0.1 - vitest: - specifier: 4.1.9 - version: 4.1.9(@types/node@26.0.1)(vite@6.3.4(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.29.2)(tsx@4.22.4)(yaml@2.9.0)) - wrangler: - specifier: 4.105.0 - version: 4.105.0(@cloudflare/workers-types@4.20260630.1) - apps/econ: dependencies: '@repo/hono-helpers':