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

follows.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.

follows.tsBlame251 lines · 1 contributor
7aa8b99Claude1/**
2 * Block J4 — User following + personalised feed.
3 *
4 * Core graph ops and a personalised feed built on top of `activity_feed`.
5 * No caches / materialised views — the follow set is small (tens to low
6 * hundreds) and the activity_feed already carries the indexes we need.
7 */
8
9import { and, desc, eq, inArray } from "drizzle-orm";
10import { db } from "../db";
11import {
12 activityFeed,
13 repositories,
14 userFollows,
15 users,
16 type ActivityEntry,
17 type User,
18} from "../db/schema";
19
20// ----------------------------------------------------------------------------
21// Graph mutations
22// ----------------------------------------------------------------------------
23
24export async function followUser(
25 followerId: string,
26 followingId: string
27): Promise<"ok" | "self" | "already" | "error"> {
28 if (followerId === followingId) return "self";
29 try {
30 const rows = await db
31 .insert(userFollows)
32 .values({ followerId, followingId })
33 .onConflictDoNothing()
34 .returning();
35 return rows.length > 0 ? "ok" : "already";
36 } catch {
37 return "error";
38 }
39}
40
41export async function unfollowUser(
42 followerId: string,
43 followingId: string
44): Promise<boolean> {
45 const rows = await db
46 .delete(userFollows)
47 .where(
48 and(
49 eq(userFollows.followerId, followerId),
50 eq(userFollows.followingId, followingId)
51 )
52 )
53 .returning();
54 return rows.length > 0;
55}
56
57export async function isFollowing(
58 followerId: string,
59 followingId: string
60): Promise<boolean> {
61 if (followerId === followingId) return false;
62 const [row] = await db
63 .select({ f: userFollows.followerId })
64 .from(userFollows)
65 .where(
66 and(
67 eq(userFollows.followerId, followerId),
68 eq(userFollows.followingId, followingId)
69 )
70 )
71 .limit(1);
72 return !!row;
73}
74
75// ----------------------------------------------------------------------------
76// Lists
77// ----------------------------------------------------------------------------
78
79export async function listFollowers(userId: string): Promise<User[]> {
80 return db
81 .select({
82 id: users.id,
83 username: users.username,
84 email: users.email,
85 displayName: users.displayName,
86 passwordHash: users.passwordHash,
87 avatarUrl: users.avatarUrl,
88 bio: users.bio,
89 notifyEmailOnMention: users.notifyEmailOnMention,
90 notifyEmailOnAssign: users.notifyEmailOnAssign,
91 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
92 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
93 lastDigestSentAt: users.lastDigestSentAt,
94 createdAt: users.createdAt,
95 updatedAt: users.updatedAt,
96 })
97 .from(userFollows)
98 .innerJoin(users, eq(userFollows.followerId, users.id))
99 .where(eq(userFollows.followingId, userId))
100 .orderBy(desc(userFollows.createdAt));
101}
102
103export async function listFollowing(userId: string): Promise<User[]> {
104 return db
105 .select({
106 id: users.id,
107 username: users.username,
108 email: users.email,
109 displayName: users.displayName,
110 passwordHash: users.passwordHash,
111 avatarUrl: users.avatarUrl,
112 bio: users.bio,
113 notifyEmailOnMention: users.notifyEmailOnMention,
114 notifyEmailOnAssign: users.notifyEmailOnAssign,
115 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
116 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
117 lastDigestSentAt: users.lastDigestSentAt,
118 createdAt: users.createdAt,
119 updatedAt: users.updatedAt,
120 })
121 .from(userFollows)
122 .innerJoin(users, eq(userFollows.followingId, users.id))
123 .where(eq(userFollows.followerId, userId))
124 .orderBy(desc(userFollows.createdAt));
125}
126
127export async function followCounts(userId: string): Promise<{
128 followers: number;
129 following: number;
130}> {
131 const [followers, following] = await Promise.all([
132 db
133 .select({ uid: userFollows.followerId })
134 .from(userFollows)
135 .where(eq(userFollows.followingId, userId)),
136 db
137 .select({ uid: userFollows.followingId })
138 .from(userFollows)
139 .where(eq(userFollows.followerId, userId)),
140 ]);
141 return { followers: followers.length, following: following.length };
142}
143
144// ----------------------------------------------------------------------------
145// Feed
146// ----------------------------------------------------------------------------
147
148export interface FeedEntry {
149 activity: ActivityEntry;
150 actor: { id: string; username: string; displayName: string | null };
151 repository: { id: string; name: string; ownerId: string; isPrivate: boolean };
152 ownerUsername: string;
153}
154
155/**
156 * Activity feed filtered to the users the viewer follows. We cap at 200
157 * following edges to bound the IN list. Private repos are excluded unless
158 * the viewer owns them.
159 */
160export async function feedForUser(
161 userId: string,
162 limit = 50
163): Promise<FeedEntry[]> {
164 const following = await db
165 .select({ id: userFollows.followingId })
166 .from(userFollows)
167 .where(eq(userFollows.followerId, userId))
168 .limit(200);
169 const ids = following.map((f) => f.id);
170 if (ids.length === 0) return [];
171
172 const rows = await db
173 .select({
174 activity: activityFeed,
175 actor: users,
176 repository: repositories,
177 })
178 .from(activityFeed)
179 .innerJoin(users, eq(activityFeed.userId, users.id))
180 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
181 .where(inArray(activityFeed.userId, ids))
182 .orderBy(desc(activityFeed.createdAt))
183 .limit(limit);
184
185 // Resolve owner usernames for repo links.
186 const ownerIds = Array.from(new Set(rows.map((r) => r.repository.ownerId)));
187 let ownerMap: Record<string, string> = {};
188 if (ownerIds.length) {
189 const owners = await db
190 .select({ id: users.id, username: users.username })
191 .from(users)
192 .where(inArray(users.id, ownerIds));
193 for (const o of owners) ownerMap[o.id] = o.username;
194 }
195
196 return rows
197 .filter(
198 (r) => !r.repository.isPrivate || r.repository.ownerId === userId
199 )
200 .map((r) => ({
201 activity: r.activity,
202 actor: {
203 id: r.actor.id,
204 username: r.actor.username,
205 displayName: r.actor.displayName,
206 },
207 repository: {
208 id: r.repository.id,
209 name: r.repository.name,
210 ownerId: r.repository.ownerId,
211 isPrivate: r.repository.isPrivate,
212 },
213 ownerUsername: ownerMap[r.repository.ownerId] || "",
214 }));
215}
216
217/** Human-readable verb for an activity_feed `action` token. */
218export function describeAction(action: string): string {
219 switch (action) {
220 case "push":
221 return "pushed to";
222 case "issue_open":
223 return "opened an issue in";
224 case "issue_close":
225 return "closed an issue in";
226 case "pr_open":
227 return "opened a pull request in";
228 case "pr_merge":
229 return "merged a pull request in";
230 case "pr_close":
231 return "closed a pull request in";
232 case "star":
233 return "starred";
234 case "comment":
235 return "commented in";
236 default:
237 return action.replace(/_/g, " ");
238 }
239}
240
241/** Resolve a username → user ID or null. */
242export async function resolveUserByName(
243 username: string
244): Promise<User | null> {
245 const [row] = await db
246 .select()
247 .from(users)
248 .where(eq(users.username, username))
249 .limit(1);
250 return row ?? null;
251}