orgs.ts549 lines · main
| 1 | import { NotFoundError, brivenError, newId } from '@briven/shared'; |
| 2 | import { and, eq, isNull, sql } from 'drizzle-orm'; |
| 3 | |
| 4 | import { getDb } from '../db/client.js'; |
| 5 | import { |
| 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 | */ |
| 27 | export 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 | */ |
| 41 | export 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 | */ |
| 74 | export 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 | */ |
| 167 | export 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 | */ |
| 179 | export 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 | */ |
| 207 | export 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 | */ |
| 225 | export 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 | |
| 244 | export 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 | */ |
| 261 | export 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 | |
| 285 | export 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 | */ |
| 304 | export 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 | */ |
| 348 | export 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 | |
| 358 | export 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 | */ |
| 371 | export 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 | */ |
| 397 | export 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 | */ |
| 447 | export 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 | */ |
| 491 | export 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 | |
| 527 | export 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 | } |