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.
| 5cc5d95 | 1 | /** |
| 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 | ||
| 8 | import { and, eq } from "drizzle-orm"; | |
| 9 | import { db } from "../db"; | |
| 10 | import { | |
| 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 | */ | |
| 24 | const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$/; | |
| 25 | ||
| 26 | /** Reserved slugs we never allow (collision with app routes). */ | |
| 27 | const 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 | ||
| 64 | export 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 | ||
| 72 | export 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 | */ | |
| 80 | const ORG_ROLE_RANK: Record<OrgRole, number> = { | |
| 81 | owner: 3, | |
| 82 | admin: 2, | |
| 83 | member: 1, | |
| 84 | }; | |
| 85 | ||
| 86 | export 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 | ||
| 92 | export function isValidOrgRole(s: string): s is OrgRole { | |
| 93 | return s === "owner" || s === "admin" || s === "member"; | |
| 94 | } | |
| 95 | ||
| 96 | export 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). */ | |
| 101 | export 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 | ||
| 133 | export 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 | ||
| 154 | export 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 | ||
| 173 | export 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 | ||
| 182 | export 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 | */ | |
| 204 | export const __test = { | |
| 205 | ORG_ROLE_RANK, | |
| 206 | RESERVED_SLUGS, | |
| 207 | SLUG_RE, | |
| 208 | }; |