more interaction endpoints

This commit is contained in:
Devin Zuczek
2026-07-05 17:07:28 -04:00
parent bc7e4d718c
commit 8e255b0129
4 changed files with 131 additions and 31 deletions
+37
View File
@@ -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<Interaction> {
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<Interaction> {
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<Interaction> {
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
+26
View File
@@ -17,6 +17,8 @@ import {
getRoomsByCreator,
getRoomsByIds,
getSimilarRooms,
removeCheer,
removeFavorite,
getVisitedRooms,
searchRooms,
setRoomDescription,
@@ -270,6 +272,18 @@ const app = new Hono<App>()
)
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<App>()
)
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,
@@ -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([])
})
})