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

pinned-repos.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.

pinned-repos.tsBlame165 lines · 1 contributor
8ec29ffClaude1/**
2 * Block J13 — Pinned repositories on user profile.
3 *
4 * Users select up to 6 repositories to feature on their profile. Pure helpers
5 * here are driven by the route handler + unit tests; DB helpers swallow
6 * errors and return safe defaults.
7 */
8
9import { and, asc, eq, inArray } from "drizzle-orm";
10import { db } from "../db";
11import {
12 pinnedRepositories,
13 repositories,
14 users,
15} from "../db/schema";
16
17export const MAX_PINS = 6;
18
19/**
20 * Sanitise an incoming pin list: de-dup, preserve first-seen order, clamp
21 * to MAX_PINS, drop empty strings. Pure; used by the form handler.
22 */
23export function sanitisePinIds(ids: Array<string | null | undefined>): string[] {
24 const seen = new Set<string>();
25 const out: string[] = [];
26 for (const id of ids) {
27 if (!id) continue;
28 const trimmed = id.trim();
29 if (!trimmed) continue;
30 if (seen.has(trimmed)) continue;
31 seen.add(trimmed);
32 out.push(trimmed);
33 if (out.length >= MAX_PINS) break;
34 }
35 return out;
36}
37
38/**
39 * List a user's pinned repos in display order, joined to the owning user so
40 * the profile can render "owner/name" links. Returns at most MAX_PINS rows.
41 */
42export async function listPinnedForUser(userId: string): Promise<
43 Array<{
44 repositoryId: string;
45 name: string;
46 ownerUsername: string;
47 description: string | null;
48 starCount: number;
49 forkCount: number;
50 isPrivate: boolean;
51 position: number;
52 }>
53> {
54 try {
55 const rows = await db
56 .select({
57 repositoryId: pinnedRepositories.repositoryId,
58 position: pinnedRepositories.position,
59 name: repositories.name,
60 description: repositories.description,
61 starCount: repositories.starCount,
62 forkCount: repositories.forkCount,
63 isPrivate: repositories.isPrivate,
64 ownerUsername: users.username,
65 })
66 .from(pinnedRepositories)
67 .innerJoin(
68 repositories,
69 eq(repositories.id, pinnedRepositories.repositoryId)
70 )
71 .innerJoin(users, eq(users.id, repositories.ownerId))
72 .where(eq(pinnedRepositories.userId, userId))
73 .orderBy(asc(pinnedRepositories.position))
74 .limit(MAX_PINS);
75 return rows;
76 } catch (err) {
77 console.error("[pinned-repos] listPinnedForUser failed:", err);
78 return [];
79 }
80}
81
82/**
83 * Replace a user's entire pin set with the given ordered list of repo IDs.
84 * - Ignores IDs the user can't pin (private repos they don't own).
85 * - Uses delete-then-insert for atomicity-lite.
86 * Returns the resulting canonical list of pinned repo IDs.
87 */
88export async function setPinsForUser(
89 userId: string,
90 repoIds: string[]
91): Promise<string[]> {
92 const clean = sanitisePinIds(repoIds);
93 if (clean.length === 0) {
94 try {
95 await db
96 .delete(pinnedRepositories)
97 .where(eq(pinnedRepositories.userId, userId));
98 } catch (err) {
99 console.error("[pinned-repos] clear failed:", err);
100 }
101 return [];
102 }
103 try {
104 const rows = await db
105 .select({
106 id: repositories.id,
107 ownerId: repositories.ownerId,
108 isPrivate: repositories.isPrivate,
109 })
110 .from(repositories)
111 .where(inArray(repositories.id, clean));
112 const byId = new Map(rows.map((r) => [r.id, r]));
113 // Filter: must exist; if private, viewer must be the owner.
114 const allowed = clean.filter((id) => {
115 const r = byId.get(id);
116 if (!r) return false;
117 if (r.isPrivate && r.ownerId !== userId) return false;
118 return true;
119 });
120 await db
121 .delete(pinnedRepositories)
122 .where(eq(pinnedRepositories.userId, userId));
123 if (allowed.length === 0) return [];
124 await db.insert(pinnedRepositories).values(
125 allowed.map((repositoryId, position) => ({
126 userId,
127 repositoryId,
128 position,
129 }))
130 );
131 return allowed;
132 } catch (err) {
133 console.error("[pinned-repos] setPinsForUser failed:", err);
134 return [];
135 }
136}
137
138/**
139 * List candidate repositories for the pin-chooser UI — repos the user owns
140 * (public + private) and repos they've starred (public only here; we don't
141 * want a user to pin someone else's private repo). Capped to 50 rows each.
142 */
143export async function listPinCandidates(userId: string): Promise<
144 Array<{ id: string; name: string; ownerUsername: string; isPrivate: boolean }>
145> {
146 try {
147 const owned = await db
148 .select({
149 id: repositories.id,
150 name: repositories.name,
151 ownerUsername: users.username,
152 isPrivate: repositories.isPrivate,
153 })
154 .from(repositories)
155 .innerJoin(users, eq(users.id, repositories.ownerId))
156 .where(eq(repositories.ownerId, userId))
157 .limit(50);
158 return owned;
159 } catch (err) {
160 console.error("[pinned-repos] listPinCandidates failed:", err);
161 return [];
162 }
163}
164
165export const __internal = { MAX_PINS, sanitisePinIds };