Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

reactions.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

reactions.tsBlame135 lines · 1 contributor
6fc53bdClaude1/**
2 * Reactions helper — aggregate + toggle logic over the `reactions` table.
3 * Universal target pointer: (targetType, targetId).
4 */
5
6import { and, eq, sql } from "drizzle-orm";
7import { db } from "../db";
8import { reactions } from "../db/schema";
9
10export type TargetType = "issue" | "pr" | "issue_comment" | "pr_comment";
11
12export const ALLOWED_TARGETS: TargetType[] = [
13 "issue",
14 "pr",
15 "issue_comment",
16 "pr_comment",
17];
18
19export type Emoji =
20 | "thumbs_up"
21 | "thumbs_down"
22 | "rocket"
23 | "heart"
24 | "eyes"
25 | "laugh"
26 | "hooray"
27 | "confused";
28
29export const ALLOWED_EMOJIS: Emoji[] = [
30 "thumbs_up",
31 "thumbs_down",
32 "rocket",
33 "heart",
34 "eyes",
35 "laugh",
36 "hooray",
37 "confused",
38];
39
40export const EMOJI_GLYPH: Record<Emoji, string> = {
41 thumbs_up: "\uD83D\uDC4D",
42 thumbs_down: "\uD83D\uDC4E",
43 rocket: "\uD83D\uDE80",
44 heart: "\u2764\uFE0F",
45 eyes: "\uD83D\uDC40",
46 laugh: "\uD83D\uDE04",
47 hooray: "\uD83C\uDF89",
48 confused: "\uD83D\uDE15",
49};
50
51export function isAllowedEmoji(x: unknown): x is Emoji {
52 return typeof x === "string" && (ALLOWED_EMOJIS as string[]).includes(x);
53}
54
55export function isAllowedTarget(x: unknown): x is TargetType {
56 return typeof x === "string" && (ALLOWED_TARGETS as string[]).includes(x);
57}
58
59export type ReactionSummary = {
60 emoji: Emoji;
61 count: number;
62 reactedByMe: boolean;
63};
64
65/**
66 * Load reaction counts for a single target, + whether current user reacted.
67 */
68export async function summariseReactions(
69 targetType: TargetType,
70 targetId: string,
71 currentUserId: string | null | undefined
72): Promise<ReactionSummary[]> {
73 try {
74 const rows = await db
75 .select({
76 emoji: reactions.emoji,
77 count: sql<number>`count(*)::int`,
78 mine: sql<number>`sum(case when ${reactions.userId} = ${currentUserId || null} then 1 else 0 end)::int`,
79 })
80 .from(reactions)
81 .where(
82 and(
83 eq(reactions.targetType, targetType),
84 eq(reactions.targetId, targetId)
85 )
86 )
87 .groupBy(reactions.emoji);
88
89 return rows
90 .filter((r) => isAllowedEmoji(r.emoji))
91 .map((r) => ({
92 emoji: r.emoji as Emoji,
93 count: Number(r.count) || 0,
94 reactedByMe: Number(r.mine) > 0,
95 }));
96 } catch {
97 return [];
98 }
99}
100
101/**
102 * Toggle the user's reaction. Returns the new `reactedByMe` state.
103 */
104export async function toggleReaction(
105 userId: string,
106 targetType: TargetType,
107 targetId: string,
108 emoji: Emoji
109): Promise<{ added: boolean }> {
110 const existing = await db
111 .select({ id: reactions.id })
112 .from(reactions)
113 .where(
114 and(
115 eq(reactions.userId, userId),
116 eq(reactions.targetType, targetType),
117 eq(reactions.targetId, targetId),
118 eq(reactions.emoji, emoji)
119 )
120 )
121 .limit(1);
122
123 if (existing.length > 0) {
124 await db.delete(reactions).where(eq(reactions.id, existing[0].id));
125 return { added: false };
126 }
127
128 await db.insert(reactions).values({
129 userId,
130 targetType,
131 targetId,
132 emoji,
133 });
134 return { added: true };
135}