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.tsBlame227 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[]> {
7a945a7Claude80 // Use `db.select().from(users)` (no projection) so the returned type
81 // tracks the live `users` schema. L1 (Sleep Mode) + M2 (push prefs)
82 // added 6 new columns; enumerating column-by-column made this list
83 // drift out of sync.
7aa8b99Claude84 return db
7a945a7Claude85 .select()
86 .from(users)
87 .innerJoin(userFollows, eq(userFollows.followerId, users.id))
7aa8b99Claude88 .where(eq(userFollows.followingId, userId))
7a945a7Claude89 .orderBy(desc(userFollows.createdAt))
90 .then((rows) => rows.map((r) => r.users));
7aa8b99Claude91}
92
93export async function listFollowing(userId: string): Promise<User[]> {
94 return db
7a945a7Claude95 .select()
96 .from(users)
97 .innerJoin(userFollows, eq(userFollows.followingId, users.id))
7aa8b99Claude98 .where(eq(userFollows.followerId, userId))
7a945a7Claude99 .orderBy(desc(userFollows.createdAt))
100 .then((rows) => rows.map((r) => r.users));
7aa8b99Claude101}
102
103export async function followCounts(userId: string): Promise<{
104 followers: number;
105 following: number;
106}> {
107 const [followers, following] = await Promise.all([
108 db
109 .select({ uid: userFollows.followerId })
110 .from(userFollows)
111 .where(eq(userFollows.followingId, userId)),
112 db
113 .select({ uid: userFollows.followingId })
114 .from(userFollows)
115 .where(eq(userFollows.followerId, userId)),
116 ]);
117 return { followers: followers.length, following: following.length };
118}
119
120// ----------------------------------------------------------------------------
121// Feed
122// ----------------------------------------------------------------------------
123
124export interface FeedEntry {
125 activity: ActivityEntry;
126 actor: { id: string; username: string; displayName: string | null };
127 repository: { id: string; name: string; ownerId: string; isPrivate: boolean };
128 ownerUsername: string;
129}
130
131/**
132 * Activity feed filtered to the users the viewer follows. We cap at 200
133 * following edges to bound the IN list. Private repos are excluded unless
134 * the viewer owns them.
135 */
136export async function feedForUser(
137 userId: string,
138 limit = 50
139): Promise<FeedEntry[]> {
140 const following = await db
141 .select({ id: userFollows.followingId })
142 .from(userFollows)
143 .where(eq(userFollows.followerId, userId))
144 .limit(200);
145 const ids = following.map((f) => f.id);
146 if (ids.length === 0) return [];
147
148 const rows = await db
149 .select({
150 activity: activityFeed,
151 actor: users,
152 repository: repositories,
153 })
154 .from(activityFeed)
155 .innerJoin(users, eq(activityFeed.userId, users.id))
156 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
157 .where(inArray(activityFeed.userId, ids))
158 .orderBy(desc(activityFeed.createdAt))
159 .limit(limit);
160
161 // Resolve owner usernames for repo links.
162 const ownerIds = Array.from(new Set(rows.map((r) => r.repository.ownerId)));
163 let ownerMap: Record<string, string> = {};
164 if (ownerIds.length) {
165 const owners = await db
166 .select({ id: users.id, username: users.username })
167 .from(users)
168 .where(inArray(users.id, ownerIds));
169 for (const o of owners) ownerMap[o.id] = o.username;
170 }
171
172 return rows
173 .filter(
174 (r) => !r.repository.isPrivate || r.repository.ownerId === userId
175 )
176 .map((r) => ({
177 activity: r.activity,
178 actor: {
179 id: r.actor.id,
180 username: r.actor.username,
181 displayName: r.actor.displayName,
182 },
183 repository: {
184 id: r.repository.id,
185 name: r.repository.name,
186 ownerId: r.repository.ownerId,
187 isPrivate: r.repository.isPrivate,
188 },
189 ownerUsername: ownerMap[r.repository.ownerId] || "",
190 }));
191}
192
193/** Human-readable verb for an activity_feed `action` token. */
194export function describeAction(action: string): string {
195 switch (action) {
196 case "push":
197 return "pushed to";
198 case "issue_open":
199 return "opened an issue in";
200 case "issue_close":
201 return "closed an issue in";
202 case "pr_open":
203 return "opened a pull request in";
204 case "pr_merge":
205 return "merged a pull request in";
206 case "pr_close":
207 return "closed a pull request in";
208 case "star":
209 return "starred";
210 case "comment":
211 return "commented in";
212 default:
213 return action.replace(/_/g, " ");
214 }
215}
216
217/** Resolve a username → user ID or null. */
218export async function resolveUserByName(
219 username: string
220): Promise<User | null> {
221 const [row] = await db
222 .select()
223 .from(users)
224 .where(eq(users.username, username))
225 .limit(1);
226 return row ?? null;
227}