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

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

orgs.tsBlame208 lines · 1 contributor
5cc5d95Claude1/**
2 * Organization helpers (Block B1).
3 *
4 * Keeps slug validation + role math out of the route handler so they
5 * can be unit-tested without touching the database.
6 */
7
8import { and, eq } from "drizzle-orm";
9import { db } from "../db";
10import {
11 organizations,
12 orgMembers,
13 teams,
14 teamMembers,
15 users,
16 type OrgRole,
17 type TeamRole,
18} from "../db/schema";
19
20/**
21 * Slug rules: 2–39 chars, [a-z0-9-], cannot start or end with a hyphen,
22 * cannot contain consecutive hyphens. Same shape as GitHub org slugs.
23 */
24const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$/;
25
26/** Reserved slugs we never allow (collision with app routes). */
27const RESERVED_SLUGS: ReadonlySet<string> = new Set([
28 "api",
29 "admin",
30 "auth",
31 "login",
32 "logout",
33 "register",
34 "settings",
35 "dashboard",
36 "explore",
37 "search",
38 "new",
39 "notifications",
40 "theme",
41 "healthz",
42 "readyz",
43 "metrics",
44 "orgs",
45 "org",
46 "team",
47 "teams",
48 "user",
49 "users",
50 "repo",
51 "repos",
52 "issues",
53 "pulls",
54 "releases",
55 "shortcuts",
56 "help",
57 "docs",
58 "ask",
59 "about",
60 "static",
61 "assets",
62]);
63
64export function isValidSlug(s: string): boolean {
65 if (!s || s.length < 2 || s.length > 39) return false;
66 if (!SLUG_RE.test(s)) return false;
67 if (s.includes("--")) return false;
68 if (RESERVED_SLUGS.has(s)) return false;
69 return true;
70}
71
72export function normalizeSlug(s: string): string {
73 return s.trim().toLowerCase();
74}
75
76/**
77 * Role comparisons. Higher rank beats lower.
78 * owner > admin > member
79 */
80const ORG_ROLE_RANK: Record<OrgRole, number> = {
81 owner: 3,
82 admin: 2,
83 member: 1,
84};
85
86export function orgRoleAtLeast(have: string, need: OrgRole): boolean {
87 const h = ORG_ROLE_RANK[have as OrgRole] ?? 0;
88 const n = ORG_ROLE_RANK[need];
89 return h >= n;
90}
91
92export function isValidOrgRole(s: string): s is OrgRole {
93 return s === "owner" || s === "admin" || s === "member";
94}
95
96export function isValidTeamRole(s: string): s is TeamRole {
97 return s === "maintainer" || s === "member";
98}
99
100/** Fetch an org + the current user's role in it (if any). */
101export async function loadOrgForUser(
102 slug: string,
103 userId: string | undefined
104): Promise<{
105 org: typeof organizations.$inferSelect | null;
106 role: OrgRole | null;
107}> {
108 try {
109 const [orgRow] = await db
110 .select()
111 .from(organizations)
112 .where(eq(organizations.slug, slug))
113 .limit(1);
114 if (!orgRow) return { org: null, role: null };
115 if (!userId) return { org: orgRow, role: null };
116 const [mem] = await db
117 .select({ role: orgMembers.role })
118 .from(orgMembers)
119 .where(
120 and(eq(orgMembers.orgId, orgRow.id), eq(orgMembers.userId, userId))
121 )
122 .limit(1);
123 return {
124 org: orgRow,
125 role: mem && isValidOrgRole(mem.role) ? mem.role : null,
126 };
127 } catch (err) {
128 console.error("[orgs] loadOrgForUser:", err);
129 return { org: null, role: null };
130 }
131}
132
133export async function listOrgsForUser(userId: string) {
134 try {
135 const rows = await db
136 .select({
137 id: organizations.id,
138 slug: organizations.slug,
139 name: organizations.name,
140 description: organizations.description,
141 avatarUrl: organizations.avatarUrl,
142 role: orgMembers.role,
143 })
144 .from(orgMembers)
145 .innerJoin(organizations, eq(organizations.id, orgMembers.orgId))
146 .where(eq(orgMembers.userId, userId));
147 return rows;
148 } catch (err) {
149 console.error("[orgs] listOrgsForUser:", err);
150 return [];
151 }
152}
153
154export async function listOrgMembers(orgId: string) {
155 try {
156 return await db
157 .select({
158 userId: orgMembers.userId,
159 role: orgMembers.role,
160 username: users.username,
161 displayName: users.displayName,
162 avatarUrl: users.avatarUrl,
163 })
164 .from(orgMembers)
165 .innerJoin(users, eq(users.id, orgMembers.userId))
166 .where(eq(orgMembers.orgId, orgId));
167 } catch (err) {
168 console.error("[orgs] listOrgMembers:", err);
169 return [];
170 }
171}
172
173export async function listTeamsForOrg(orgId: string) {
174 try {
175 return await db.select().from(teams).where(eq(teams.orgId, orgId));
176 } catch (err) {
177 console.error("[orgs] listTeamsForOrg:", err);
178 return [];
179 }
180}
181
182export async function listTeamMembers(teamId: string) {
183 try {
184 return await db
185 .select({
186 userId: teamMembers.userId,
187 role: teamMembers.role,
188 username: users.username,
189 displayName: users.displayName,
190 avatarUrl: users.avatarUrl,
191 })
192 .from(teamMembers)
193 .innerJoin(users, eq(users.id, teamMembers.userId))
194 .where(eq(teamMembers.teamId, teamId));
195 } catch (err) {
196 console.error("[orgs] listTeamMembers:", err);
197 return [];
198 }
199}
200
201/**
202 * Exported for unit tests.
203 */
204export const __test = {
205 ORG_ROLE_RANK,
206 RESERVED_SLUGS,
207 SLUG_RE,
208};