orgs.ts549 lines · main
1import { NotFoundError, brivenError, newId } from '@briven/shared';
2import { and, eq, isNull, sql } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import {
6 orgMembers,
7 organizations,
8 users,
9 type OrgRole,
10 type Organization,
11 type ProjectTier,
12} from '../db/schema.js';
13
14/**
15 * Tier → maximum number of orgs a user is allowed to OWN (not belong to).
16 *
17 * Free: 1 — that's the auto-created personal org; no team creation.
18 * Pro: unlimited — paying users can spin up as many team workspaces
19 * as they need. Matches industry norms (Vercel, GitHub, Netlify);
20 * Convex doesn't even surface a team count on their pricing page —
21 * their cap is per-developer-seat, not per-workspace.
22 * Team: unlimited.
23 *
24 * Independent of how many orgs they're INVITED to as a member — being
25 * added to 10 other Team orgs doesn't burn your own cap.
26 */
27export const ORG_LIMIT_BY_TIER: Record<ProjectTier, number> = {
28 free: 1,
29 pro: Infinity,
30 team: Infinity,
31};
32
33/**
34 * Resolve the caller's default organisation — today this is always their
35 * `personal=true` org. Auto-created via the Better Auth `user.create.after`
36 * hook in [lib/auth.ts](../lib/auth.ts) and self-healed here for users who
37 * predate the hook (or whose hook firing failed).
38 *
39 * Throws NotFoundError only if the user themselves no longer exists.
40 */
41export async function getDefaultOrgForUser(userId: string): Promise<Organization> {
42 const db = getDb();
43 const [row] = await db
44 .select()
45 .from(organizations)
46 .where(
47 and(
48 eq(organizations.createdBy, userId),
49 eq(organizations.personal, true),
50 isNull(organizations.deletedAt),
51 ),
52 )
53 .limit(1);
54 if (row) return row;
55
56 // Lazy backfill: anyone created before the user-create hook landed
57 // (everyone signed up between migration 0010 and the hook deploy)
58 // hits this on their first /v1/me. Idempotent.
59 const [user] = await db
60 .select({ id: users.id, email: users.email, name: users.name })
61 .from(users)
62 .where(eq(users.id, userId))
63 .limit(1);
64 if (!user) throw new NotFoundError('user', userId);
65 return ensurePersonalOrg({ userId: user.id, email: user.email, name: user.name });
66}
67
68/**
69 * Idempotently produce the personal org for a user. Used both at signup
70 * (Better Auth `user.create.after` hook) and as the lazy-create fallback
71 * inside `getDefaultOrgForUser`. Safe to call concurrently — racing
72 * inserts collapse to a re-read of the winner.
73 */
74export async function ensurePersonalOrg(input: {
75 userId: string;
76 email: string;
77 name: string | null;
78}): Promise<Organization> {
79 const db = getDb();
80
81 const [existing] = await db
82 .select()
83 .from(organizations)
84 .where(
85 and(
86 eq(organizations.createdBy, input.userId),
87 eq(organizations.personal, true),
88 isNull(organizations.deletedAt),
89 ),
90 )
91 .limit(1);
92 if (existing) return existing;
93
94 // Mirrors migration 0010 so personal orgs are addressable by user id alone.
95 const orgId = `org_${input.userId}`;
96 const baseSlug = slugFromEmail(input.email) || 'me';
97 // Suffixed with a stable userId fragment so two users with the same email
98 // local-part can both sign up. Slug is internal-only today (URLs stay
99 // org-less until Phase 3); aesthetics matter less than uniqueness.
100 const slug = `${baseSlug}-${input.userId.slice(-6).toLowerCase()}`;
101 const displayName = (input.name?.trim() || baseSlug).slice(0, 200);
102
103 try {
104 const [row] = await db
105 .insert(organizations)
106 .values({
107 id: orgId,
108 slug,
109 name: displayName,
110 personal: true,
111 createdBy: input.userId,
112 })
113 .returning();
114 if (!row) {
115 throw new brivenError('personal_org_create_failed', 'org insert returned no row', {
116 status: 500,
117 });
118 }
119 await db
120 .insert(orgMembers)
121 .values({ orgId: row.id, userId: input.userId, role: 'owner' })
122 .onConflictDoNothing();
123 return row;
124 } catch (err) {
125 const code = (err as { code?: string }).code;
126 const msg = err instanceof Error ? err.message : '';
127 // 23505 = unique_violation (PostgresError). Also catch drizzle-
128 // wrapped errors where the code is nested or the message contains
129 // "duplicate key" — the postgres driver sometimes wraps the native
130 // error inside a driver-level Error whose .code is undefined.
131 const isDuplicate =
132 code === '23505' ||
133 msg.includes('duplicate key') ||
134 msg.includes('unique constraint');
135 if (isDuplicate) {
136 const [refound] = await db
137 .select()
138 .from(organizations)
139 .where(eq(organizations.id, orgId))
140 .limit(1);
141 if (refound) {
142 await db
143 .insert(orgMembers)
144 .values({ orgId: refound.id, userId: input.userId, role: 'owner' })
145 .onConflictDoNothing();
146 // Self-heal: if the org was created with personal=false (pre-
147 // migration edge case), fix it now so the next getDefaultOrgForUser
148 // hits the fast path instead of re-entering ensurePersonalOrg.
149 if (!refound.personal) {
150 await db
151 .update(organizations)
152 .set({ personal: true, updatedAt: new Date() })
153 .where(eq(organizations.id, refound.id));
154 }
155 return refound;
156 }
157 }
158 throw err;
159 }
160}
161
162/**
163 * Email local-part → kebab slug, stripped of edge dashes. Mirrors the
164 * SQL `regexp_replace` in migration 0010. Pure function — extracted so
165 * unit tests don't need a database.
166 */
167export function slugFromEmail(email: string): string {
168 const local = email.split('@')[0] ?? '';
169 return local
170 .toLowerCase()
171 .replace(/[^a-z0-9]+/g, '-')
172 .replace(/^-+|-+$/g, '');
173}
174
175/**
176 * All orgs a user is a member of (personal first). Used by the future
177 * org-switcher UI; today only the billing/project routes that want a list.
178 */
179export async function listOrgsForUser(userId: string): Promise<Organization[]> {
180 const db = getDb();
181 const rows = await db
182 .select({
183 id: organizations.id,
184 slug: organizations.slug,
185 name: organizations.name,
186 personal: organizations.personal,
187 createdBy: organizations.createdBy,
188 createdAt: organizations.createdAt,
189 updatedAt: organizations.updatedAt,
190 deletedAt: organizations.deletedAt,
191 })
192 .from(organizations)
193 .innerJoin(orgMembers, eq(orgMembers.orgId, organizations.id))
194 .where(and(eq(orgMembers.userId, userId), isNull(organizations.deletedAt)));
195 // Personal org sorted first; rest alphabetical by name.
196 return rows.sort((a, b) => {
197 if (a.personal !== b.personal) return a.personal ? -1 : 1;
198 return a.name.localeCompare(b.name);
199 });
200}
201
202/**
203 * Membership guard — every org-scoped route calls this before reading data
204 * under an orgId it didn't choose itself. Returns false rather than
205 * throwing so callers can decide the HTTP status (403 vs 404).
206 */
207export async function isMember(orgId: string, userId: string): Promise<boolean> {
208 const db = getDb();
209 const [row] = await db
210 .select({ userId: orgMembers.userId })
211 .from(orgMembers)
212 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
213 .limit(1);
214 return Boolean(row);
215}
216
217/**
218 * Whether this user may create another org on their current tier. Counts
219 * orgs they CREATED, not orgs they're a member of — being invited to 10
220 * other Team orgs still leaves a Free user able to create their 1 personal.
221 *
222 * Not wired to a route this project (no create-org UI yet). Exists with a
223 * unit-tested rule so Phase 3 only needs to plug it in.
224 */
225export async function canUserCreateAnotherOrg(
226 userId: string,
227 tier: ProjectTier,
228): Promise<{ allowed: true } | { allowed: false; reason: string }> {
229 const db = getDb();
230 const rows = await db
231 .select({ id: organizations.id })
232 .from(organizations)
233 .where(and(eq(organizations.createdBy, userId), isNull(organizations.deletedAt)));
234 const limit = ORG_LIMIT_BY_TIER[tier];
235 if (rows.length >= limit) {
236 return {
237 allowed: false,
238 reason: `tier ${tier} allows at most ${limit} owned organisation${limit === 1 ? '' : 's'}`,
239 };
240 }
241 return { allowed: true };
242}
243
244export interface CreateOrgInput {
245 createdBy: string;
246 name: string;
247 slug: string;
248 personal?: boolean;
249 role?: OrgRole;
250}
251
252/**
253 * Create a new org + make the creator its owner. Not wired to any route
254 * this project; exposed for future invite/create-org flows.
255 */
256/**
257 * Rename a team org. Personal orgs are immutable (their name comes from
258 * the user's profile) — rename is rejected. Returns the updated row,
259 * or null when the org doesn't exist / isn't writable by the caller.
260 */
261export async function renameOrg(args: {
262 orgId: string;
263 userId: string;
264 name: string;
265}): Promise<Organization | null> {
266 const db = getDb();
267 const [target] = await db
268 .select()
269 .from(organizations)
270 .where(and(eq(organizations.id, args.orgId), isNull(organizations.deletedAt)))
271 .limit(1);
272 if (!target) return null;
273 if (target.personal) return null; // can't rename a personal org via this path
274 const member = await isOrgMember(args.userId, args.orgId);
275 if (!member) return null;
276
277 const [row] = await db
278 .update(organizations)
279 .set({ name: args.name, updatedAt: new Date() })
280 .where(eq(organizations.id, args.orgId))
281 .returning();
282 return row ?? null;
283}
284
285export type PromoteResult =
286 | { ok: true; org: Organization }
287 | { ok: false; reason: string };
288
289/**
290 * Graduate a personal org to a real team. Personal orgs are the
291 * single-member default Better Auth creates per user; promoting flips
292 * `personal=false` so the rest of the dashboard's team affordances
293 * (invite members, rename via the normal patch route, etc.) light up.
294 *
295 * Constraints:
296 * - org must exist and not be soft-deleted
297 * - org must currently be personal (already-team returns `already_team`,
298 * not an error — caller may have double-clicked through a slow form)
299 * - caller must be an owner of the org. Personal orgs only ever have
300 * one member (the creator at role=owner) so this is the natural
301 * gate — no path for a non-creator to graduate someone's personal
302 * space.
303 */
304export async function promotePersonalOrgToTeam(args: {
305 orgId: string;
306 userId: string;
307 name: string;
308}): Promise<PromoteResult> {
309 const db = getDb();
310 const [target] = await db
311 .select()
312 .from(organizations)
313 .where(and(eq(organizations.id, args.orgId), isNull(organizations.deletedAt)))
314 .limit(1);
315 if (!target) return { ok: false, reason: 'org_not_found' };
316 if (!target.personal) return { ok: false, reason: 'already_team' };
317
318 const [membership] = await db
319 .select({ role: orgMembers.role })
320 .from(orgMembers)
321 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.userId, args.userId)))
322 .limit(1);
323 if (!membership) return { ok: false, reason: 'not_a_member' };
324 if (membership.role !== 'owner') return { ok: false, reason: 'owner_role_required' };
325
326 const trimmedName = args.name.trim();
327 if (trimmedName.length === 0) return { ok: false, reason: 'name_required' };
328
329 const [row] = await db
330 .update(organizations)
331 .set({
332 personal: false,
333 name: trimmedName,
334 updatedAt: new Date(),
335 })
336 .where(eq(organizations.id, args.orgId))
337 .returning();
338 if (!row) return { ok: false, reason: 'update_failed' };
339 return { ok: true, org: row };
340}
341
342/**
343 * Returns true when the user belongs to the given org. Used at project
344 * creation to validate the orgId the dashboard sends actually belongs
345 * to the caller — defends against an attacker scrolling through org
346 * ids and dropping projects into other people's orgs.
347 */
348export async function isOrgMember(userId: string, orgId: string): Promise<boolean> {
349 const db = getDb();
350 const [row] = await db
351 .select({ orgId: orgMembers.orgId })
352 .from(orgMembers)
353 .where(and(eq(orgMembers.userId, userId), eq(orgMembers.orgId, orgId)))
354 .limit(1);
355 return Boolean(row);
356}
357
358export interface OrgMemberRow {
359 userId: string;
360 email: string;
361 name: string | null;
362 role: OrgRole;
363 joinedAt: Date;
364}
365
366/**
367 * List every member of an org with their profile basics. Used by the
368 * team-detail page to render the members section above pending invites.
369 * Caller is expected to have already checked membership.
370 */
371export async function listOrgMembers(orgId: string): Promise<OrgMemberRow[]> {
372 const db = getDb();
373 const rows = await db
374 .select({
375 userId: orgMembers.userId,
376 email: users.email,
377 name: users.name,
378 role: orgMembers.role,
379 joinedAt: orgMembers.createdAt,
380 })
381 .from(orgMembers)
382 .innerJoin(users, eq(users.id, orgMembers.userId))
383 .where(eq(orgMembers.orgId, orgId));
384 // Owner first, then alphabetical by email. Matches what GitHub / Vercel do.
385 const roleRank: Record<OrgRole, number> = { owner: 0, admin: 1, developer: 2, viewer: 3 };
386 return rows.sort((a, b) => {
387 if (a.role !== b.role) return roleRank[a.role] - roleRank[b.role];
388 return a.email.localeCompare(b.email);
389 });
390}
391
392/**
393 * Soft-delete a team org. Refuses on personal orgs and on team orgs that
394 * still own non-deleted projects — those would otherwise be orphaned. The
395 * caller is told to delete (or move) the projects first.
396 */
397export async function deleteOrg(args: {
398 orgId: string;
399 userId: string;
400}): Promise<{ ok: true } | { ok: false; reason: string }> {
401 const db = getDb();
402 const [org] = await db
403 .select()
404 .from(organizations)
405 .where(and(eq(organizations.id, args.orgId), isNull(organizations.deletedAt)))
406 .limit(1);
407 if (!org) return { ok: false, reason: 'org not found' };
408 if (org.personal) return { ok: false, reason: 'cannot delete a personal org' };
409
410 const member = await isOrgMember(args.userId, args.orgId);
411 if (!member) return { ok: false, reason: 'not a member of this org' };
412
413 // Only owners can delete a team org.
414 const [memberRow] = await db
415 .select({ role: orgMembers.role })
416 .from(orgMembers)
417 .where(and(eq(orgMembers.userId, args.userId), eq(orgMembers.orgId, args.orgId)))
418 .limit(1);
419 if (memberRow?.role !== 'owner') {
420 return { ok: false, reason: 'only an owner can delete a team' };
421 }
422
423 // Bounce if there are live projects in the org. The dashboard surfaces
424 // this with a friendly "delete or move projects first" message.
425 const liveProjects = await db.execute(sql`
426 select 1 from projects
427 where org_id = ${args.orgId}
428 and deleted_at is null
429 limit 1
430 `);
431 if (liveProjects.length > 0) {
432 return { ok: false, reason: 'team still has projects — delete or move them first' };
433 }
434
435 await db
436 .update(organizations)
437 .set({ deletedAt: new Date(), updatedAt: new Date() })
438 .where(eq(organizations.id, args.orgId));
439 return { ok: true };
440}
441
442/**
443 * Change a member's role inside an org. Same last-owner protection as
444 * removeOrgMember: if the only owner is being demoted, the request is
445 * rejected and the caller is told to promote another member first.
446 */
447export async function changeOrgMemberRole(args: {
448 orgId: string;
449 userId: string;
450 newRole: OrgRole;
451}): Promise<{ ok: true } | { ok: false; reason: string }> {
452 const db = getDb();
453 const [org] = await db
454 .select()
455 .from(organizations)
456 .where(eq(organizations.id, args.orgId))
457 .limit(1);
458 if (!org) return { ok: false, reason: 'org not found' };
459 if (org.personal) return { ok: false, reason: 'cannot change roles in a personal org' };
460
461 const [target] = await db
462 .select()
463 .from(orgMembers)
464 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.userId, args.userId)))
465 .limit(1);
466 if (!target) return { ok: false, reason: 'user is not a member of this org' };
467 if (target.role === args.newRole) return { ok: true };
468
469 if (target.role === 'owner' && args.newRole !== 'owner') {
470 const owners = await db
471 .select({ userId: orgMembers.userId })
472 .from(orgMembers)
473 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.role, 'owner')));
474 if (owners.length <= 1) {
475 return { ok: false, reason: 'cannot demote the last owner — promote another member first' };
476 }
477 }
478
479 await db
480 .update(orgMembers)
481 .set({ role: args.newRole })
482 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.userId, args.userId)));
483 return { ok: true };
484}
485
486/**
487 * Remove a member from an org. Refuses to remove the last owner — every
488 * team must always have at least one owner so billing/admin actions remain
489 * possible. Personal orgs reject removal entirely.
490 */
491export async function removeOrgMember(args: {
492 orgId: string;
493 userId: string;
494}): Promise<{ removed: true } | { removed: false; reason: string }> {
495 const db = getDb();
496 const [org] = await db
497 .select()
498 .from(organizations)
499 .where(eq(organizations.id, args.orgId))
500 .limit(1);
501 if (!org) return { removed: false, reason: 'org not found' };
502 if (org.personal) return { removed: false, reason: 'cannot remove members from a personal org' };
503
504 const [target] = await db
505 .select()
506 .from(orgMembers)
507 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.userId, args.userId)))
508 .limit(1);
509 if (!target) return { removed: false, reason: 'user is not a member of this org' };
510
511 if (target.role === 'owner') {
512 const owners = await db
513 .select({ userId: orgMembers.userId })
514 .from(orgMembers)
515 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.role, 'owner')));
516 if (owners.length <= 1) {
517 return { removed: false, reason: 'cannot remove the last owner — promote another member first' };
518 }
519 }
520
521 await db
522 .delete(orgMembers)
523 .where(and(eq(orgMembers.orgId, args.orgId), eq(orgMembers.userId, args.userId)));
524 return { removed: true };
525}
526
527export async function createOrg(input: CreateOrgInput): Promise<Organization> {
528 const db = getDb();
529 const id = newId('org');
530 const [row] = await db
531 .insert(organizations)
532 .values({
533 id,
534 slug: input.slug,
535 name: input.name,
536 personal: input.personal ?? false,
537 createdBy: input.createdBy,
538 })
539 .returning();
540 if (!row) {
541 throw new brivenError('org_insert_failed', 'org insert returned no row', { status: 500 });
542 }
543 await db.insert(orgMembers).values({
544 orgId: row.id,
545 userId: input.createdBy,
546 role: input.role ?? 'owner',
547 });
548 return row;
549}