Files
gerbeur/api/services/comment-service.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

223 lines
7.4 KiB
TypeScript

/**
* Stores the comments on dumps, threaded by `parent_id`. The comment routes
* call it and broadcast what it returns. It leaves checking that the caller
* may see the dump to them.
*
* @module
*/
import { APIException } from "../lib/api-exception.ts";
import type { Comment } from "../../shared/comment.ts";
import { commentRowToApi, isCommentRow } from "../model/comment.ts";
import { db } from "../db/connection.ts";
import {
notifyDumpOwnerNewComment,
notifyMentions,
} from "./notification-service.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
/** The select list of a `CommentRow`, from `comments c` joined to `users u`. */
const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
function fetchComment(commentId: string): Comment {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
).get(commentId);
if (!row) throw new APIException(404, "Comment not found");
if (!isCommentRow(row)) {
throw new APIException(500, "Malformed comment data");
}
return commentRowToApi(row);
}
/**
* Returns the id of the dump comment `commentId` is posted under. The likers
* route uses it to check that the caller may see that dump.
*
* @throws {APIException} 404 when there is no such comment.
*/
export function getCommentDumpId(commentId: string): string {
const row = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`).get(
commentId,
) as { dump_id: string } | undefined;
if (!row) {
throw new APIException(404, "Comment not found");
}
return row.dump_id;
}
/**
* Returns every comment on dump `dumpId`, oldest first, for
* `GET /api/dumps/:dumpId/comments`. Deleted comments are included, emptied,
* so the client can keep their replies in place.
*
* @throws {APIException} 500 when a row doesn't parse as a comment.
*/
export function getComments(dumpId: string): Comment[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
).all(dumpId);
if (!rows.every(isCommentRow)) {
throw new APIException(500, "Malformed comment data");
}
return rows.map(commentRowToApi);
}
/**
* Posts a comment on dump `dumpId`, replying to `parentId` if given, for
* `POST /api/dumps/:dumpId/comments`. It notifies the dump's poster and the
* users it mentions, unless the dump is private, and links the comment's
* attachments and backlinks. The route has checked that the caller may see
* the dump.
*
* @throws {APIException} 400 when `parentId` isn't a comment on the same dump.
*/
export function createComment(
dumpId: string,
userId: string,
body: string,
parentId?: string,
): Comment {
if (parentId !== undefined) {
const parent = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`)
.get(parentId) as { dump_id: string } | undefined;
if (parent?.dump_id !== dumpId) {
throw new APIException(400, "The comment replied to isn't on this dump");
}
}
const id = crypto.randomUUID();
const createdAt = new Date();
db.prepare(
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
).run(
id,
dumpId,
userId,
parentId ?? null,
body.trim(),
createdAt.toISOString(),
);
const comment = fetchComment(id);
const dumpRow = db.prepare(
`SELECT title, is_private FROM dumps WHERE id = ?;`,
).get(dumpId) as { title: string; is_private: number } | undefined;
notifyDumpOwnerNewComment(userId, id, dumpId);
if (dumpRow && !dumpRow.is_private) {
notifyMentions(userId, body, "comment", id, dumpRow.title, dumpId);
}
linkAttachments(body, "comment", id);
relinkBacklinks("comment", id, dumpId, body);
return comment;
}
/**
* The dump a comment is on, as {@link updateComment} and {@link deleteComment}
* return it: what the route needs to choose the broadcast's audience.
*/
export interface CommentDump {
id: string;
isPrivate: boolean;
/** The dump's poster, the only one who sees a private dump's comments. */
userId: string;
}
/**
* Returns the dump comment `commentId` is on.
*
* @throws {APIException} 404 when there is no such comment.
*/
function commentDump(commentId: string): CommentDump {
const row = db.prepare(
`SELECT d.id, d.is_private, d.user_id FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as
| { id: string; is_private: number; user_id: string }
| undefined;
if (!row) {
throw new APIException(404, "Comment not found");
}
return {
id: row.id,
isPrivate: Boolean(row.is_private),
userId: row.user_id,
};
}
/**
* Replaces a comment's body, for `PATCH /api/comments/:commentId`.
* `canModerate` lets someone other than the author do it. It notifies, in the
* author's name, the users it mentions for the first time, unless the dump is
* private, and relinks the attachments and backlinks. The route broadcasts the
* comment to those who may see its `dump`.
*
* @throws {APIException} 404 when there is no such comment, 400 when it is
* deleted, 403 when the user is neither its author nor a moderator.
*/
export function updateComment(
commentId: string,
body: string,
requestingUserId: string,
canModerate: boolean,
): { comment: Comment; dump: CommentDump } {
const dump = commentDump(commentId);
const existing = fetchComment(commentId);
if (existing.deleted) {
throw new APIException(400, "Cannot edit a deleted comment");
}
if (existing.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to edit this comment");
}
const now = new Date().toISOString();
db.prepare(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?;`).run(
body.trim(),
now,
commentId,
);
if (!dump.isPrivate) {
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
dump.id,
) as { title: string } | undefined;
// A moderator's edit keeps the author's words, so the author mentions.
notifyMentions(
existing.userId,
body,
"comment",
commentId,
dumpRow?.title ?? "",
dump.id,
);
}
linkAttachments(body, "comment", commentId);
relinkBacklinks("comment", commentId, dump.id, body);
return { comment: fetchComment(commentId), dump };
}
/**
* Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
* someone other than the author do it. The body is emptied and the backlinks
* dropped, but the row stays so its replies keep their place. The route
* broadcasts the deletion to those who may see the returned dump.
*
* @throws {APIException} 404 when there is no such comment, 403 when the user
* is neither its author nor a moderator.
*/
export function deleteComment(
commentId: string,
requestingUserId: string,
canModerate: boolean,
): CommentDump {
const dump = commentDump(commentId);
const comment = fetchComment(commentId);
if (comment.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to delete this comment");
}
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
commentId,
);
relinkBacklinks("comment", commentId, dump.id, "");
return dump;
}