223 lines
7.4 KiB
TypeScript
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;
|
|
}
|