213 lines
7.0 KiB
TypeScript
213 lines
7.0 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 { APIErrorCode } from "../../shared/api.ts";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import type { Comment } from "../../shared/comment.ts";
|
|
import { type SQLOutputValue } from "node:sqlite";
|
|
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 || !isCommentRow(row as Record<string, SQLOutputValue>)) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
|
}
|
|
if (!isCommentRow(row)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
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(APIErrorCode.NOT_FOUND, 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(
|
|
APIErrorCode.SERVER_ERROR,
|
|
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, and links the comment's attachments and backlinks.
|
|
* `parentId` isn't checked to be a comment on the same dump.
|
|
*/
|
|
export function createComment(
|
|
dumpId: string,
|
|
userId: string,
|
|
body: string,
|
|
parentId?: string,
|
|
): Comment {
|
|
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 FROM dumps WHERE id = ?;`).get(
|
|
dumpId,
|
|
) as { title: string } | undefined;
|
|
notifyDumpOwnerNewComment(userId, id, dumpId);
|
|
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
|
linkAttachments(body, 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 the users
|
|
* it mentions for the first time 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, 401 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(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
|
}
|
|
const existing = fetchComment(commentId);
|
|
if (existing.deleted) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
400,
|
|
"Cannot edit a deleted comment",
|
|
);
|
|
}
|
|
if (existing.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(
|
|
APIErrorCode.UNAUTHORIZED,
|
|
401,
|
|
"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,
|
|
);
|
|
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
|
row.dump_id,
|
|
) as { title: string } | undefined;
|
|
notifyMentions(
|
|
requestingUserId,
|
|
body,
|
|
"comment",
|
|
commentId,
|
|
dumpRow?.title ?? "",
|
|
row.dump_id,
|
|
);
|
|
linkAttachments(body, 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, 401 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(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
|
}
|
|
const comment = fetchComment(commentId);
|
|
if (comment.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(
|
|
APIErrorCode.UNAUTHORIZED,
|
|
401,
|
|
"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) };
|
|
}
|