/** * Stores likes on comments and keeps each comment's `like_count` in step with * them. Likes have no HTTP endpoint to cast or remove. The WebSocket route * (`routes/ws.ts`) calls this module on `comment_like_cast` and * `comment_like_remove` messages, then broadcasts the new count. The comment * routes use it only to list a comment's likers. * * @module */ import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; import { db, transaction } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts"; /** * Returned to the WebSocket route by {@link castCommentLike} and * {@link removeCommentLike}. The route acks the liker with `likeCount`, then * broadcasts it to every client, or to the liker's alone when the comment's * dump `isPrivate`. */ export interface LikeResult { likeCount: number; isPrivate: boolean; } /** * Returns whether the comment's dump is private, or throws a 404 when `userId` * can't see it. */ function commentVisibility(commentId: string, userId: string): boolean { const row = db.prepare( `SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id WHERE c.id = ?;`, ).get(commentId) as { is_private: number; user_id: string } | undefined; if (!row || (row.is_private && row.user_id !== userId)) { throw new APIException(404, "Comment not found"); } return Boolean(row.is_private); } /** * Records `userId`'s like on a comment and notifies the comment's author. The * notification is best-effort: if it fails, the like still counts. * * @throws {APIException} 404 when the comment doesn't exist or sits on someone * else's private dump, 409 when the user already likes it. */ export function castCommentLike( commentId: string, userId: string, ): LikeResult { const isPrivate = commentVisibility(commentId, userId); let likeCount: number; try { likeCount = transaction(() => { db.prepare( `INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`, ).run(commentId, userId, new Date().toISOString()); db.prepare( `UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`, ).run(commentId); const row = db.prepare( `SELECT like_count FROM comments WHERE id = ?;`, ).get(commentId) as { like_count: number }; return row.like_count; }); } catch (err) { if (err instanceof Error && err.message.includes("UNIQUE constraint")) { throw new APIException(409, "Already liked"); } throw err; } // The like is committed: a failed notification must not report it as failed. try { notifyCommentOwnerLike(userId, commentId); } catch { /* ignore */ } return { likeCount, isPrivate }; } /** * Takes back `userId`'s like on a comment. It doesn't check that the user can * still see the comment, since a like on a dump that has gone private is still * theirs to take back. * * @throws {APIException} 404 when the user doesn't like the comment. */ export function removeCommentLike( commentId: string, userId: string, ): LikeResult { const dumpRow = db.prepare( `SELECT d.is_private FROM comments c JOIN dumps d ON d.id = c.dump_id WHERE c.id = ?;`, ).get(commentId) as { is_private: number } | undefined; const isPrivate = Boolean(dumpRow?.is_private); return transaction(() => { const result = db.prepare( `DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`, ).run(commentId, userId); if (result.changes === 0) { throw new APIException(404, "Like not found"); } db.prepare( `UPDATE comments SET like_count = like_count - 1 WHERE id = ?;`, ).run(commentId); const row = db.prepare( `SELECT like_count FROM comments WHERE id = ?;`, ).get(commentId) as { like_count: number }; return { likeCount: row.like_count, isPrivate }; }); } /** * Returns the ids of the comments `userId` likes. The WebSocket route sends * them in its `welcome` message, and the client shows those like buttons * pressed. */ export function getUserCommentLikes(userId: string): string[] { const rows = db.prepare( `SELECT comment_id FROM comment_likes WHERE user_id = ?;`, ).all(userId) as { comment_id: string }[]; return rows.map((r) => r.comment_id); } /** * Returns a page of the users who like a comment, latest like first, for * `GET /api/comments/:commentId/likers`. It doesn't check that the caller may * see the comment: the route does that first, with `getDump`. * * @throws {APIException} 500 when a row doesn't parse as a user. */ export function getCommentLikers( commentId: string, page: number, limit: number, ): Page { const offset = (page - 1) * limit; const rawRows = db.prepare( `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by INNER JOIN comment_likes cl ON cl.comment_id = ? AND cl.user_id = u.id ORDER BY cl.created_at DESC LIMIT ? OFFSET ?;`, ).all(commentId, limit + 1, offset); if (!rawRows.every(isUserRow)) { throw new APIException(500, "Malformed user data"); } return pageOf(rawRows.map(userRowToApi), limit); }