From f3bd19a4ea9566d0f65f1c49df00ccc6c6cdae3d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 1 Nov 2026 17:32:30 +0000 Subject: [PATCH] feat: mark selected notifications read --- apps/server/src/routes/api.ts | 33 ++++++++++++++++- apps/server/src/routes/openapi.ts | 20 +++++++++- docs/API.md | 2 +- test/api.test.ts | 61 +++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/api.ts b/apps/server/src/routes/api.ts index 753e688..192e7a6 100644 --- a/apps/server/src/routes/api.ts +++ b/apps/server/src/routes/api.ts @@ -438,7 +438,38 @@ export function apiRoutes(services: Services) { app.post('/api/v1/notifications/read', async (c) => { const viewer = c.get('viewer'); if (!viewer.user) return c.json({ error: 'unauthorized' }, 401); - await markNotificationsRead(viewer.user.id); + + // An omitted body keeps the original mark-everything behavior. Clients + // that handle one notification at a time can clear only that alert, so a + // later reply on the same followed topic can raise and email a new one. + const raw = await c.req.raw.clone().text(); + let ids: number[] | undefined; + if (raw.trim()) { + let body: unknown; + try { + body = JSON.parse(raw) as unknown; + } catch { + return c.json({ error: 'invalid_json' }, 400); + } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return c.json({ error: 'invalid_body' }, 400); + } + + if ('ids' in body) { + const requested = (body as { ids?: unknown }).ids; + if ( + !Array.isArray(requested) || + requested.length > 100 || + requested.some((id) => !Number.isSafeInteger(id) || Number(id) < 1) + ) { + return c.json({ error: 'invalid_notification_ids' }, 400); + } + ids = [...new Set(requested as number[])]; + if (ids.length === 0) return c.json({ ok: true }); + } + } + + await markNotificationsRead(viewer.user.id, ids); return c.json({ ok: true }); }); diff --git a/apps/server/src/routes/openapi.ts b/apps/server/src/routes/openapi.ts index 2fc83e0..147fc8b 100644 --- a/apps/server/src/routes/openapi.ts +++ b/apps/server/src/routes/openapi.ts @@ -267,8 +267,26 @@ export function openApiDocument(baseUrl: string, settings: Settings): Record { assert.ok(afterBoard.forums.every((f) => f.unread === 0), 'nothing is unread after a board-wide mark'); }); + it('marks selected notifications read without clearing another member’s alerts', async () => { + const recipient = await core.createUser({ email: 'notice-recipient@example.com', username: 'notice-recipient' }); + const recipientToken = await core.mintToken({ userId: recipient.id, label: 'test' }); + const outsider = await core.createUser({ email: 'notice-outsider@example.com', username: 'notice-outsider' }); + const first = await core.notify({ + userId: recipient.id, + kind: 'reply', + subjectType: 'topic', + subjectId: topicId, + title: 'First reply', + }); + const second = await core.notify({ + userId: recipient.id, + kind: 'reply', + subjectType: 'topic', + subjectId: topicId, + title: 'Second reply', + }); + const outsiderNotice = await core.notify({ + userId: outsider.id, + kind: 'reply', + subjectType: 'topic', + subjectId: topicId, + title: 'Private to another member', + }); + assert.ok(first && second && outsiderNotice); + + const authed = (path: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + headers.set('authorization', `Bearer ${recipientToken}`); + return app.fetch(new Request(url(path), { ...init, headers })); + }; + const selected = await authed('/api/v1/notifications/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ids: [first.id, outsiderNotice.id] }), + }); + assert.equal(selected.status, 200); + + const unread = (await (await authed('/api/v1/notifications?unread=1')).json()) as { + notifications: { id: number }[]; + }; + assert.deepEqual(unread.notifications.map((notice) => notice.id), [second.id]); + + const markAll = await authed('/api/v1/notifications/read', { method: 'POST' }); + assert.equal(markAll.status, 200); + const afterAll = (await (await authed('/api/v1/notifications?unread=1')).json()) as { + notifications: { id: number }[]; + }; + assert.deepEqual(afterAll.notifications, []); + + const outsiderToken = await core.mintToken({ userId: outsider.id, label: 'test' }); + const outsiderUnread = await app.fetch( + new Request(url('/api/v1/notifications?unread=1'), { + headers: { authorization: `Bearer ${outsiderToken}` }, + }), + ); + const outsiderData = (await outsiderUnread.json()) as { notifications: { id: number }[] }; + assert.deepEqual(outsiderData.notifications.map((notice) => notice.id), [outsiderNotice.id]); + }); + it('says what a token may do in each forum, so a client need not find out by being refused', async () => { // A reply-only forum is how a feed is published to a board: the crawler // opens the topics and members discuss them. Posting into one answers 403,