[chat] fix favorite thread

This commit is contained in:
Devin Zuczek
2026-10-20 11:31:28 -04:00
parent 2091483cc3
commit f04b9bd66b
3 changed files with 53 additions and 14 deletions
+26 -9
View File
@@ -20,6 +20,7 @@ import {
CreatePartyChatResponse,
CreateThreadRequest,
CreateThreadResponse,
FavoriteChatResponse,
FavoriteThreadRequest,
form,
json,
@@ -572,7 +573,7 @@ async function formField(c: Context<App>, name: string): Promise<string | undefi
/**
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze,
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
* and the "3 when the caller isn't on the thread" behaviour.
*/
@@ -1361,28 +1362,44 @@ const app = new Hono<App>()
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
// snoozing, it's a per-member flag that pins the thread in their own inbox.
//
// Answers the client's `FavoriteChatResponse` — a bare `{ ChatResult, IsFavorited }`
// object, not the bare ChatResult integer the neighbouring thread actions answer.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/favorite',
chatResultRoute(
'Favorite or unfavorite a thread',
[
describeRoute({
tags: ['Chat'],
summary: 'Favorite or unfavorite a thread',
description: [
'Like snoozing, a per-member flag that pins the thread in the caller’s own inbox and',
'leaves everyone else’s untouched.',
'leaves everyone else’s untouched. Answers the client’s `FavoriteChatResponse`, a bare',
'`{ ChatResult, IsFavorited }` object — not the bare ChatResult integer the other thread',
'actions answer.',
].join(' '),
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
security: AUTHED,
parameters: [THREAD_ID_PARAM],
requestBody: form(FavoriteThreadRequest, 'The favorite flag'),
responses: {
200: json(
FavoriteChatResponse,
'ChatResult 0 and the new flag on success; ChatResult 3 when the caller isn’t on the thread'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
return c.json({ ChatResult: CHAT_MEMBERSHIP_NOT_FOUND, IsFavorited: false })
}
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
return c.json(CHAT_SUCCESS)
const isFavorited = await formBool(c, 'favorite')
await setThreadFavorited(c.env.DB, chatThreadId, id, isFavorited)
return c.json({ ChatResult: CHAT_SUCCESS, IsFavorited: isFavorited })
}
)
+12
View File
@@ -388,6 +388,18 @@ export const FavoriteThreadRequest = z.object({
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
/**
* `PUT|POST /thread/:id/favorite` response — the client's `FavoriteChatResponse`, a bare
* two-key object with no envelope. Unlike the other thread actions it is NOT a bare
* ChatResult integer: the client parses this object either way.
*/
export const FavoriteChatResponse = z.object({
ChatResult,
IsFavorited: z
.boolean()
.describe('The caller’s favorite flag after the call (false when they aren’t on the thread)'),
})
// ---- Shared parameters -----------------------------------------------------
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
+14 -4
View File
@@ -2164,13 +2164,17 @@ describe('PUT /thread/:id/favorite', () => {
const res = await favorite(caller, chatThreadId, 'favorite=True')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
// The client's FavoriteChatResponse: a bare 2-key object, no envelope.
expect(await res.json()).toEqual({ ChatResult: 0, IsFavorited: true })
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
// Per-member, like snoozing: the other player's inbox is untouched.
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)
await favorite(caller, chatThreadId, 'favorite=False')
expect(await (await favorite(caller, chatThreadId, 'favorite=False')).json()).toEqual({
ChatResult: 0,
IsFavorited: false,
})
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
})
@@ -2178,13 +2182,19 @@ describe('PUT /thread/:id/favorite', () => {
const caller = 893003
const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toEqual({
ChatResult: 0,
IsFavorited: true,
})
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
})
it('refuses a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toEqual({
ChatResult: 3,
IsFavorited: false,
})
})
it('401s without a token', async () => {