|
| 1 | +/** |
| 2 | + * Comment reaction handlers (Tier 1 of the best-in-class comments RFC). |
| 3 | + * |
| 4 | + * Business logic for toggling reactions and reading aggregate counts. Route |
| 5 | + * files stay thin wrappers; these return `ApiResult<T>`. |
| 6 | + */ |
| 7 | + |
| 8 | +import type { Kysely } from "kysely"; |
| 9 | + |
| 10 | +import { |
| 11 | + CommentReactionRepository, |
| 12 | + type ReactionCounts, |
| 13 | +} from "#db/repositories/comment-reaction.js"; |
| 14 | +import type { Database } from "#db/types.js"; |
| 15 | + |
| 16 | +import type { ApiResult } from "../types.js"; |
| 17 | + |
| 18 | +/** Max reactions a single voter may register per window before throttling. */ |
| 19 | +const REACTION_RATE_LIMIT = 30; |
| 20 | +const REACTION_RATE_WINDOW_MINUTES = 10; |
| 21 | + |
| 22 | +/** |
| 23 | + * Reactions the system accepts. Positive-only for now (matches the shipped |
| 24 | + * widget); kept as an allowlist so a voter can't spam arbitrary reaction |
| 25 | + * strings and bloat a comment's count map. Extend (or make configurable) as |
| 26 | + * the UI grows. |
| 27 | + */ |
| 28 | +const ALLOWED_REACTIONS: ReadonlySet<string> = new Set(["like"]); |
| 29 | + |
| 30 | +export interface ReactionToggleResult { |
| 31 | + commentId: string; |
| 32 | + reaction: string; |
| 33 | + /** true if the reaction was added, false if an existing one was removed */ |
| 34 | + reacted: boolean; |
| 35 | + /** updated counts for this comment after the toggle */ |
| 36 | + counts: ReactionCounts; |
| 37 | +} |
| 38 | + |
| 39 | +export interface ReactionCountsResult { |
| 40 | + /** comment id -> reaction counts */ |
| 41 | + reactions: Record<string, ReactionCounts>; |
| 42 | + /** comment id -> reactions the current voter has active (omitted if anonymous) */ |
| 43 | + viewer?: Record<string, string[]>; |
| 44 | +} |
| 45 | + |
| 46 | +/** |
| 47 | + * Toggle a reaction for a voter on an approved comment belonging to the given |
| 48 | + * content item. Rate-limited per voter. |
| 49 | + */ |
| 50 | +export async function handleReactionToggle( |
| 51 | + db: Kysely<Database>, |
| 52 | + params: { |
| 53 | + collection: string; |
| 54 | + contentId: string; |
| 55 | + commentId: string; |
| 56 | + reaction: string; |
| 57 | + voterHash: string; |
| 58 | + }, |
| 59 | +): Promise<ApiResult<ReactionToggleResult>> { |
| 60 | + try { |
| 61 | + const { collection, contentId, commentId, reaction, voterHash } = params; |
| 62 | + |
| 63 | + if (!ALLOWED_REACTIONS.has(reaction)) { |
| 64 | + return { |
| 65 | + success: false, |
| 66 | + error: { code: "VALIDATION_ERROR", message: "Unsupported reaction" }, |
| 67 | + }; |
| 68 | + } |
| 69 | + |
| 70 | + // The comment must exist, be approved, and belong to this content item. |
| 71 | + const comment = await db |
| 72 | + .selectFrom("_emdash_comments") |
| 73 | + .select(["id", "status"]) |
| 74 | + .where("id", "=", commentId) |
| 75 | + .where("collection", "=", collection) |
| 76 | + .where("content_id", "=", contentId) |
| 77 | + .executeTakeFirst(); |
| 78 | + |
| 79 | + if (!comment) { |
| 80 | + return { success: false, error: { code: "NOT_FOUND", message: "Comment not found" } }; |
| 81 | + } |
| 82 | + if (comment.status !== "approved") { |
| 83 | + return { |
| 84 | + success: false, |
| 85 | + error: { code: "COMMENT_NOT_APPROVED", message: "Cannot react to this comment" }, |
| 86 | + }; |
| 87 | + } |
| 88 | + |
| 89 | + const repo = new CommentReactionRepository(db); |
| 90 | + |
| 91 | + const recent = await repo.countRecentByVoter(voterHash, REACTION_RATE_WINDOW_MINUTES); |
| 92 | + if (recent >= REACTION_RATE_LIMIT) { |
| 93 | + return { |
| 94 | + success: false, |
| 95 | + error: { code: "RATE_LIMITED", message: "Too many reactions. Please try again later." }, |
| 96 | + }; |
| 97 | + } |
| 98 | + |
| 99 | + const { reacted } = await repo.toggle({ commentId, reaction, voterHash }); |
| 100 | + const countsMap = await repo.countsForComments([commentId]); |
| 101 | + |
| 102 | + return { |
| 103 | + success: true, |
| 104 | + data: { commentId, reaction, reacted, counts: countsMap.get(commentId) ?? {} }, |
| 105 | + }; |
| 106 | + } catch { |
| 107 | + return { |
| 108 | + success: false, |
| 109 | + error: { code: "REACTION_TOGGLE_ERROR", message: "Failed to toggle reaction" }, |
| 110 | + }; |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +/** |
| 115 | + * Read aggregate reaction counts for every approved comment on a content item, |
| 116 | + * plus (optionally) which reactions the current voter has active. |
| 117 | + */ |
| 118 | +export async function handleReactionCounts( |
| 119 | + db: Kysely<Database>, |
| 120 | + collection: string, |
| 121 | + contentId: string, |
| 122 | + voterHash?: string, |
| 123 | +): Promise<ApiResult<ReactionCountsResult>> { |
| 124 | + try { |
| 125 | + const comments = await db |
| 126 | + .selectFrom("_emdash_comments") |
| 127 | + .select("id") |
| 128 | + .where("collection", "=", collection) |
| 129 | + .where("content_id", "=", contentId) |
| 130 | + .where("status", "=", "approved") |
| 131 | + .execute(); |
| 132 | + |
| 133 | + const ids = comments.map((c) => c.id); |
| 134 | + const repo = new CommentReactionRepository(db); |
| 135 | + |
| 136 | + const countsMap = await repo.countsForComments(ids); |
| 137 | + const reactions: Record<string, ReactionCounts> = {}; |
| 138 | + for (const [id, counts] of countsMap) { |
| 139 | + reactions[id] = counts; |
| 140 | + } |
| 141 | + |
| 142 | + const data: ReactionCountsResult = { reactions }; |
| 143 | + |
| 144 | + if (voterHash) { |
| 145 | + const viewerMap = await repo.viewerReactions(ids, voterHash); |
| 146 | + const viewer: Record<string, string[]> = {}; |
| 147 | + for (const [id, list] of viewerMap) { |
| 148 | + viewer[id] = list; |
| 149 | + } |
| 150 | + data.viewer = viewer; |
| 151 | + } |
| 152 | + |
| 153 | + return { success: true, data }; |
| 154 | + } catch { |
| 155 | + return { |
| 156 | + success: false, |
| 157 | + error: { code: "REACTION_COUNTS_ERROR", message: "Failed to read reactions" }, |
| 158 | + }; |
| 159 | + } |
| 160 | +} |
0 commit comments