Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion apps/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/routes/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,26 @@ export function openApiDocument(baseUrl: string, settings: Settings): Record<str
'/api/v1/notifications/read': {
post: {
operationId: 'markNotificationsRead',
summary: 'Mark everything read.',
summary: 'Mark selected notifications read, or everything when ids is omitted.',
security: [{ bearerAuth: [] }],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ids: {
type: 'array',
maxItems: 100,
items: { type: 'integer', minimum: 1 },
},
},
additionalProperties: false,
},
},
},
},
responses: { '200': json('ok'), ...errors },
},
},
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Needs a token, and the same permissions the member has on the page.
| `POST /api/v1/forums/{slug}/topics` | `{ "title": "…", "body": "…", "format": "markdown" }` |
| `POST /api/v1/topics/{id}/posts` | `{ "body": "…", "format": "markdown" }` |
| `GET /api/v1/notifications` | `?unread=1` for only the unread ones |
| `POST /api/v1/notifications/read` | Mark everything read |
| `POST /api/v1/notifications/read` | `{ "ids": [12, 13] }` marks those notifications read; omit the body to mark everything read |
| `POST /api/v1/read` | Mark every topic on the board read |
| `POST /api/v1/forums/{slug}/read` | Mark every topic in one forum, and its subforums, read |

Expand Down
61 changes: 61 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,67 @@ describe('the API and MCP surfaces', () => {
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,
Expand Down
Loading