Skip to content

Commit cf17c9f

Browse files
marcusbellamyshaw-cellclaudeascorbic
authored
feat(comments): reactions + Wilson 'Best' sort (Tier 1) (#1540)
* feat(comments): add reactions with Wilson "Best" sort Tier 1 of the best-in-class comments RFC. Visitors can react (like) to approved comments; reactions are stored first-party in a new _emdash_comment_reactions table, deduped per voter by a salted IP hash, and served via a public, honeypot- and rate-limited endpoint at GET/POST /_emdash/api/comments/:collection/:contentId/reactions. The <Comments> component gains two opt-in props: `reactions` (render a like button + live counts) and `sort="best"` (Reddit-style Wilson lower-bound ranking). Posting is progressively enhanced via a tiny inline script emitted only when reactions are enabled. Additive and backward-compatible: new table, new route, new optional props with behavior-preserving defaults. Includes a changeset and tests. AI disclosure: implemented with assistance from Claude (claude-opus-4-8); all code verified against the repository and the full test suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(comments): register the public reactions route in the Astro integration The reactions endpoint (GET/POST /_emdash/api/comments/:collection/:contentId/reactions) needs an explicit injectRoute entry in injectCoreRoutes — without it the path falls through to the page handler. Verified end-to-end against the demo: toggle on/off and aggregate counts return the expected JSON. AI disclosure: implemented with assistance from Claude (claude-opus-4-8). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harden(comments): fix toggle race, allowlist reactions, document IP-bucket limit Adversarial self-review follow-ups on the reactions prototype: - toggle() no longer does read-then-write. A concurrent double-toggle from the same voter could both see "no row" and have the second INSERT violate the unique index, surfacing as a 500. Now an idempotent INSERT ... ON CONFLICT DO NOTHING branches on whether a row was written, so it never throws. - handleReactionToggle rejects reactions outside an allowlist (currently just "like", matching the shipped widget) so a caller can't spam arbitrary reaction strings and bloat a comment's count map. - The public reactions route documents that the salted-IP voter hash collapses to a shared "unknown" bucket without a trusted IP (per-visitor dedup is the Tier 2 visitor-identity primitive), mirroring the comment ingest route. Adds handler-level tests (happy path, unsupported reaction, non-approved and missing comment, concurrent-toggle invariant). lint + typecheck clean; 82 comment tests pass. AI disclosure: implemented with assistance from Claude (claude-opus-4-8). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update .changeset/comment-reactions.md --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Matt Kane <m@mk.gg>
1 parent 0bfab91 commit cf17c9f

16 files changed

Lines changed: 1002 additions & 6 deletions

File tree

.changeset/comment-reactions.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"emdash": minor
3+
---
4+
5+
Add comment reactions
6+
7+
Visitors can now react to approved comments (positive-only "like" by default,
8+
extensible to other reaction types). Reactions are deduped per voter via IP hash.
9+
10+
The `<Comments>` component gains two opt-in props:
11+
12+
- `reactions` — render a like button per comment and attach live counts.
13+
- `sort="best"` — order top-level comments by a Reddit-style Wilson score
14+
lower bound (`sort="oldest"`, the previous behavior, remains the default).
15+
16+
Posting is progressively enhanced (a tiny inline script, no framework island)
17+
and emitted only when `reactions` is enabled, so pages that don't use reactions
18+
ship zero additional JavaScript. Fully additive and backward-compatible: a new
19+
table, a new route, and new optional props with behavior-preserving defaults.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
}

packages/core/src/api/schemas/comments.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ export const createCommentBody = z
1515
})
1616
.meta({ id: "CreateCommentBody" });
1717

18+
export const createReactionBody = z
19+
.object({
20+
commentId: z.string().min(1),
21+
/** Reaction name. Positive-only ("like") by default; extensible. */
22+
reaction: z.string().min(1).max(20).default("like"),
23+
/** Honeypot field — hidden in the form, filled only by bots */
24+
website_url: z.string().optional(),
25+
})
26+
.meta({ id: "CreateReactionBody" });
27+
1828
export const commentStatusBody = z
1929
.object({
2030
status: z.enum(["approved", "pending", "spam", "trash"]),

packages/core/src/astro/integration/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,11 @@ export function injectCoreRoutes(
758758
entrypoint: resolveRoute("api/comments/[collection]/[contentId]/index.ts"),
759759
});
760760

761+
injectRoute({
762+
pattern: "/_emdash/api/comments/[collection]/[contentId]/reactions",
763+
entrypoint: resolveRoute("api/comments/[collection]/[contentId]/reactions.ts"),
764+
});
765+
761766
// Comment routes (admin)
762767
injectRoute({
763768
pattern: "/_emdash/api/admin/comments",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Public comment reaction endpoints (Tier 1 of the best-in-class comments RFC).
3+
*
4+
* GET /_emdash/api/comments/:collection/:contentId/reactions
5+
* - Aggregate reaction counts for the content's approved comments, plus the
6+
* current visitor's active reactions.
7+
* POST /_emdash/api/comments/:collection/:contentId/reactions
8+
* - Toggle a reaction on a comment. Public, honeypot- and rate-limit-gated.
9+
*
10+
* Inherits the `/_emdash/api/comments/` public prefix (no auth); the POST still
11+
* requires the `X-EmDash-Request: 1` CSRF header like the comment POST.
12+
*/
13+
14+
import type { APIRoute } from "astro";
15+
16+
import { apiError, apiSuccess, handleError, requireDb, unwrapResult } from "#api/error.js";
17+
import { handleReactionCounts, handleReactionToggle } from "#api/handlers/comment-reactions.js";
18+
import { hashIp } from "#api/handlers/comments.js";
19+
import { isParseError, parseBody } from "#api/parse.js";
20+
import { createReactionBody } from "#api/schemas.js";
21+
import { resolveSecretsCached } from "#config/secrets.js";
22+
import { extractRequestMeta } from "#plugins/request-meta.js";
23+
24+
export const prerender = false;
25+
26+
export const GET: APIRoute = async ({ params, request, locals }) => {
27+
const { emdash } = locals;
28+
const { collection, contentId } = params;
29+
30+
if (!collection || !contentId) {
31+
return apiError("VALIDATION_ERROR", "Collection and content ID required", 400);
32+
}
33+
34+
const dbErr = requireDb(emdash?.db);
35+
if (dbErr) return dbErr;
36+
37+
try {
38+
// Salted voter hash from request IP (same primitive as comment ip_hash).
39+
// Behind Cloudflare (CF-Connecting-IP) or a configured trusted proxy this
40+
// is per-visitor. Without a trusted IP it collapses to a shared "unknown"
41+
// bucket, so reaction dedup degrades for those visitors — a real
42+
// per-visitor token is Tier 2 (visitor identity). Operators should set
43+
// trustedProxyHeaders; see the comment ingest route for the same note.
44+
const meta = extractRequestMeta(request, emdash.config);
45+
let voterHash = "unknown";
46+
if (meta.ip) {
47+
const { ipSalt } = await resolveSecretsCached(emdash.db);
48+
voterHash = await hashIp(meta.ip, ipSalt);
49+
}
50+
51+
const result = await handleReactionCounts(emdash.db, collection, contentId, voterHash);
52+
return unwrapResult(result);
53+
} catch (error) {
54+
return handleError(error, "Failed to read reactions", "REACTION_COUNTS_ERROR");
55+
}
56+
};
57+
58+
export const POST: APIRoute = async ({ params, request, locals }) => {
59+
const { emdash } = locals;
60+
const { collection, contentId } = params;
61+
62+
if (!collection || !contentId) {
63+
return apiError("VALIDATION_ERROR", "Collection and content ID required", 400);
64+
}
65+
66+
const dbErr = requireDb(emdash?.db);
67+
if (dbErr) return dbErr;
68+
69+
try {
70+
const body = await parseBody(request, createReactionBody);
71+
if (isParseError(body)) return body;
72+
73+
// Anti-spam: honeypot — hidden field filled only by bots. Silently accept.
74+
if (body.website_url) {
75+
return apiSuccess({ reacted: false, counts: {} });
76+
}
77+
78+
const meta = extractRequestMeta(request, emdash.config);
79+
let voterHash = "unknown";
80+
if (meta.ip) {
81+
const { ipSalt } = await resolveSecretsCached(emdash.db);
82+
voterHash = await hashIp(meta.ip, ipSalt);
83+
}
84+
85+
const result = await handleReactionToggle(emdash.db, {
86+
collection,
87+
contentId,
88+
commentId: body.commentId,
89+
reaction: body.reaction,
90+
voterHash,
91+
});
92+
93+
return unwrapResult(result, 200);
94+
} catch (error) {
95+
return handleError(error, "Failed to toggle reaction", "REACTION_TOGGLE_ERROR");
96+
}
97+
};

0 commit comments

Comments
 (0)