From f04b9bd66b10fb2a483eb9dce6d06d3f72347ae0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 20 Oct 2026 11:31:28 -0400 Subject: [PATCH] [chat] fix favorite thread --- apps/chat/src/chat.app.ts | 37 ++++++++++++++++------ apps/chat/src/openapi.ts | 12 +++++++ apps/chat/src/test/integration/api.test.ts | 18 ++++++++--- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index cf478df..ed91829 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -20,6 +20,7 @@ import { CreatePartyChatResponse, CreateThreadRequest, CreateThreadResponse, + FavoriteChatResponse, FavoriteThreadRequest, form, json, @@ -572,7 +573,7 @@ async function formField(c: Context, name: string): Promise() // 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 }) } ) diff --git a/apps/chat/src/openapi.ts b/apps/chat/src/openapi.ts index e163233..1a5886b 100644 --- a/apps/chat/src/openapi.ts +++ b/apps/chat/src/openapi.ts @@ -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). */ diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts index 88b7c3a..2dfd9f5 100644 --- a/apps/chat/src/test/integration/api.test.ts +++ b/apps/chat/src/test/integration/api.test.ts @@ -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 () => {