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.
| 6fc53bd | 1 | /** |
| 2 | * Reactions helper — aggregate + toggle logic over the `reactions` table. | |
| 3 | * Universal target pointer: (targetType, targetId). | |
| 4 | */ | |
| 5 | ||
| 6 | import { and, eq, sql } from "drizzle-orm"; | |
| 7 | import { db } from "../db"; | |
| 8 | import { reactions } from "../db/schema"; | |
| 9 | ||
| 10 | export type TargetType = "issue" | "pr" | "issue_comment" | "pr_comment"; | |
| 11 | ||
| 12 | export const ALLOWED_TARGETS: TargetType[] = [ | |
| 13 | "issue", | |
| 14 | "pr", | |
| 15 | "issue_comment", | |
| 16 | "pr_comment", | |
| 17 | ]; | |
| 18 | ||
| 19 | export type Emoji = | |
| 20 | | "thumbs_up" | |
| 21 | | "thumbs_down" | |
| 22 | | "rocket" | |
| 23 | | "heart" | |
| 24 | | "eyes" | |
| 25 | | "laugh" | |
| 26 | | "hooray" | |
| 27 | | "confused"; | |
| 28 | ||
| 29 | export const ALLOWED_EMOJIS: Emoji[] = [ | |
| 30 | "thumbs_up", | |
| 31 | "thumbs_down", | |
| 32 | "rocket", | |
| 33 | "heart", | |
| 34 | "eyes", | |
| 35 | "laugh", | |
| 36 | "hooray", | |
| 37 | "confused", | |
| 38 | ]; | |
| 39 | ||
| 40 | export 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 | ||
| 51 | export function isAllowedEmoji(x: unknown): x is Emoji { | |
| 52 | return typeof x === "string" && (ALLOWED_EMOJIS as string[]).includes(x); | |
| 53 | } | |
| 54 | ||
| 55 | export function isAllowedTarget(x: unknown): x is TargetType { | |
| 56 | return typeof x === "string" && (ALLOWED_TARGETS as string[]).includes(x); | |
| 57 | } | |
| 58 | ||
| 59 | export 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 | */ | |
| 68 | export 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 | */ | |
| 104 | export 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 | } |