/** * 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; } /** * 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 unless `isPrivate`. * * @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; dumpId: string; isPrivate: boolean } { const row = db.prepare( `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, ).get(commentId) as { dump_id: string; is_private: number } | undefined; if (!row) { throw new APIException(404, "Comment not found"); } 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 (!row.is_private) { const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( row.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 ?? "", row.dump_id, ); } linkAttachments(body, "comment", commentId); relinkBacklinks("comment", commentId, row.dump_id, body); return { comment: fetchComment(commentId), dumpId: row.dump_id, isPrivate: Boolean(row.is_private), }; } /** * 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 unless `isPrivate`. * * @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, ): { dumpId: string; isPrivate: boolean } { const row = db.prepare( `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, ).get(commentId) as { dump_id: string; is_private: number } | undefined; if (!row) { throw new APIException(404, "Comment not found"); } 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, row.dump_id, ""); return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) }; }