diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index fd32b16..70b604d 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -213,6 +213,15 @@ export const SendMultipleMessagesRequest = z.object({ Data: z.string().optional().describe('The message payload; often empty'), }) +/** + * `POST /api/messages/v3/delete` JSON body — the messages the client is dropping from + * its inbox. Ids are the `Id` of a stored message, which this server has never issued: + * with no message store the list is only ever echoed back as accepted. + */ +export const DeleteMessagesRequest = z.object({ + MessageIds: z.array(z.int()).describe('Ids of the messages to delete'), +}) + /** * `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are * online, wrapped in the client's `{ success, value }` envelope. diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index 4cab4c6..56ea126 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -22,6 +22,7 @@ import { authedId, unauthorized } from '../http' import { AckResponse, AUTHED, + DeleteMessagesRequest, ErrorResponse, form, FriendOnlineCountResponse, @@ -624,6 +625,28 @@ export const socialRoutes = new Hono({ strict: false }) }), (c) => c.json([]) ) + // The inbox's delete button. A POST rather than a DELETE because the ids arrive as + // a JSON array body — the client batches a multi-select into one call, and its HTTP + // layer only ever sends a body on POST/PUT. + .post( + '/api/messages/v3/delete', + describeRoute({ + tags: ['Social'], + summary: 'Delete messages', + description: + 'Drops the given messages from the caller’s inbox. Nothing happens: there is no ' + + 'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' + + 'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' + + 'there are no ids to match and nothing to remove.\n\n' + + 'Accepted unconditionally and answered 200 with an empty body, which is what the ' + + 'client wants: it removes the rows locally and re-reads the empty list either ' + + 'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isn’t — ' + + 'the call reaches no state to protect.', + requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'), + responses: { 200: { description: 'Accepted (empty body)' } }, + }), + (c) => c.body(null, 200) + ) // How many of the caller's friends are online — the friends panel's header count. // Answered from the friend graph joined to live presence, so it agrees with the // friends the panel then lists. Auth-gated: the count is the CALLER's own. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b3354c0..af2dc9a 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -3418,6 +3418,20 @@ describe('messages', () => { expect(res.status).toBe(401) expect(await pushed()).toEqual([]) }) + + test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => { + // No message store, so no id can be real and nothing is gated — an unknown id, an + // empty list and a missing body all land the same way. + for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) { + const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + } + }) }) describe('mutual friends', () => { @@ -4837,6 +4851,7 @@ describe('openapi', () => { 'POST /api/messages/v1/friendOnlineStatus', 'POST /api/messages/v1/sendMultiple', 'POST /api/messages/v2/send', + 'POST /api/messages/v3/delete', 'POST /api/playerReputation/v1/bulk', 'POST /api/playerReputation/v2/bulk', 'POST /api/playerevents/v1/bulkInvite',