Files
gerbeur/api/routes/comments.ts
khannurien 308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00

137 lines
4.7 KiB
TypeScript

/**
* Serves the comments on dumps. Comments are broadcast over the WebSocket as
* they are posted, edited and deleted, to everyone, or to its poster alone for
* a private dump, whose comments 404 for anyone else.
*
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
* on a dump, oldest first.
* - `POST /api/dumps/:dumpId/comments` — signed in. Posts a comment, replying
* to `parentId` if given.
* - `PATCH /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Edits a comment.
* - `GET /api/comments/:commentId/likers?page=&limit=` — optional sign-in.
* Answers a page of the users who like a comment.
* - `DELETE /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Deletes a comment, leaving its place in the thread.
*
* @module
*/
import { Router } from "@oak/oak";
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Comment } from "../../shared/comment.ts";
import {
isCreateCommentRequest,
isUpdateCommentRequest,
} from "../validators/comment.ts";
import type { PublicUser } from "../../shared/user.ts";
import { toPublicUser } from "../model/user.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createComment,
deleteComment,
getCommentDumpId,
getComments,
updateComment,
} from "../services/comment-service.ts";
import { getCommentLikers } from "../services/comment-like-service.ts";
import { getDump } from "../services/dump-service.ts";
import {
broadcastCommentCreated,
broadcastCommentDeleted,
broadcastCommentUpdated,
} from "../services/ws-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api" });
router.get("/dumps/:dumpId/comments", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
const comments = getComments(dump.id);
const responseBody: APIResponse<Comment[]> = {
success: true,
data: comments,
};
ctx.response.body = responseBody;
});
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId);
const body: unknown = await ctx.request.body.json();
if (!isCreateCommentRequest(body)) {
throw new APIException(400, "Invalid comment data");
}
const comment = createComment(
dump.id,
userId,
body.body,
body.parentId ?? undefined,
);
broadcastCommentCreated(comment, dump);
const responseBody: APIResponse<Comment> = { success: true, data: comment };
ctx.response.status = 201;
ctx.response.body = responseBody;
});
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId;
const canModerate = can(ctx.state.user, "comment:moderate");
const body: unknown = await ctx.request.body.json();
if (!isUpdateCommentRequest(body)) {
throw new APIException(400, "Invalid comment data");
}
const { comment, dump } = updateComment(
ctx.params.commentId,
body.body,
userId,
canModerate,
);
broadcastCommentUpdated(comment, dump);
const responseBody: APIResponse<Comment> = { success: true, data: comment };
ctx.response.body = responseBody;
});
router.get("/comments/:commentId/likers", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dumpId = getCommentDumpId(ctx.params.commentId);
getDump(dumpId, requestingUserId); // 404s unless the caller may see the dump.
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getCommentLikers(
ctx.params.commentId,
page,
limit,
);
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true,
data: {
items: items.map(toPublicUser),
hasMore,
},
};
ctx.response.body = responseBody;
});
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId;
const canModerate = can(ctx.state.user, "comment:moderate");
const dump = deleteComment(
ctx.params.commentId,
userId,
canModerate,
);
broadcastCommentDeleted(ctx.params.commentId, dump);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
export default router;