Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame253 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,
2316901Claude94 isAdmin: users.isAdmin,
7aa8b99Claude95 createdAt: users.createdAt,
96 updatedAt: users.updatedAt,
97 })
98 .from(userFollows)
99 .innerJoin(users, eq(userFollows.followerId, users.id))
100 .where(eq(userFollows.followingId, userId))
101 .orderBy(desc(userFollows.createdAt));
102}
103
104export async function listFollowing(userId: string): Promise<User[]> {
105 return db
106 .select({
107 id: users.id,
108 username: users.username,
109 email: users.email,
110 displayName: users.displayName,
111 passwordHash: users.passwordHash,
112 avatarUrl: users.avatarUrl,
113 bio: users.bio,
114 notifyEmailOnMention: users.notifyEmailOnMention,
115 notifyEmailOnAssign: users.notifyEmailOnAssign,
116 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
117 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
118 lastDigestSentAt: users.lastDigestSentAt,
2316901Claude119 isAdmin: users.isAdmin,
7aa8b99Claude120 createdAt: users.createdAt,
121 updatedAt: users.updatedAt,
122 })
123 .from(userFollows)
124 .innerJoin(users, eq(userFollows.followingId, users.id))
125 .where(eq(userFollows.followerId, userId))
126 .orderBy(desc(userFollows.createdAt));
127}
128
129export async function followCounts(userId: string): Promise<{
130 followers: number;
131 following: number;
132}> {
133 const [followers, following] = await Promise.all([
134 db
135 .select({ uid: userFollows.followerId })
136 .from(userFollows)
137 .where(eq(userFollows.followingId, userId)),
138 db
139 .select({ uid: userFollows.followingId })
140 .from(userFollows)
141 .where(eq(userFollows.followerId, userId)),
142 ]);
143 return { followers: followers.length, following: following.length };
144}
145
146// ----------------------------------------------------------------------------
147// Feed
148// ----------------------------------------------------------------------------
149
150export interface FeedEntry {
151 activity: ActivityEntry;
152 actor: { id: string; username: string; displayName: string | null };
153 repository: { id: string; name: string; ownerId: string; isPrivate: boolean };
154 ownerUsername: string;
155}
156
157/**
158 * Activity feed filtered to the users the viewer follows. We cap at 200
159 * following edges to bound the IN list. Private repos are excluded unless
160 * the viewer owns them.
161 */
162export async function feedForUser(
163 userId: string,
164 limit = 50
165): Promise<FeedEntry[]> {
166 const following = await db
167 .select({ id: userFollows.followingId })
168 .from(userFollows)
169 .where(eq(userFollows.followerId, userId))
170 .limit(200);
171 const ids = following.map((f) => f.id);
172 if (ids.length === 0) return [];
173
174 const rows = await db
175 .select({
176 activity: activityFeed,
177 actor: users,
178 repository: repositories,
179 })
180 .from(activityFeed)
181 .innerJoin(users, eq(activityFeed.userId, users.id))
182 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
183 .where(inArray(activityFeed.userId, ids))
184 .orderBy(desc(activityFeed.createdAt))
185 .limit(limit);
186
187 // Resolve owner usernames for repo links.
188 const ownerIds = Array.from(new Set(rows.map((r) => r.repository.ownerId)));
189 let ownerMap: Record<string, string> = {};
190 if (ownerIds.length) {
191 const owners = await db
192 .select({ id: users.id, username: users.username })
193 .from(users)
194 .where(inArray(users.id, ownerIds));
195 for (const o of owners) ownerMap[o.id] = o.username;
196 }
197
198 return rows
199 .filter(
200 (r) => !r.repository.isPrivate || r.repository.ownerId === userId
201 )
202 .map((r) => ({
203 activity: r.activity,
204 actor: {
205 id: r.actor.id,
206 username: r.actor.username,
207 displayName: r.actor.displayName,
208 },
209 repository: {
210 id: r.repository.id,
211 name: r.repository.name,
212 ownerId: r.repository.ownerId,
213 isPrivate: r.repository.isPrivate,
214 },
215 ownerUsername: ownerMap[r.repository.ownerId] || "",
216 }));
217}
218
219/** Human-readable verb for an activity_feed `action` token. */
220export function describeAction(action: string): string {
221 switch (action) {
222 case "push":
223 return "pushed to";
224 case "issue_open":
225 return "opened an issue in";
226 case "issue_close":
227 return "closed an issue in";
228 case "pr_open":
229 return "opened a pull request in";
230 case "pr_merge":
231 return "merged a pull request in";
232 case "pr_close":
233 return "closed a pull request in";
234 case "star":
235 return "starred";
236 case "comment":
237 return "commented in";
238 default:
239 return action.replace(/_/g, " ");
240 }
241}
242
243/** Resolve a username → user ID or null. */
244export async function resolveUserByName(
245 username: string
246): Promise<User | null> {
247 const [row] = await db
248 .select()
249 .from(users)
250 .where(eq(users.username, username))
251 .limit(1);
252 return row ?? null;
253}