From 1eb5c8e8cb15fff847be957d31b40ae765f3c190 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Oct 2025 01:41:16 +0100 Subject: [PATCH 1/2] fix: User onboarding and organization setup logic --- packages/database/auth/drizzle-adapter.ts | 77 +++++++++++-- .../web-backend/src/Users/UsersOnboarding.ts | 108 +++++++++++++++--- 2 files changed, 156 insertions(+), 29 deletions(-) diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 5a6e0e5d8..12214e463 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -5,25 +5,73 @@ import type { MySql2Database } from "drizzle-orm/mysql2"; import type { Adapter } from "next-auth/adapters"; import type Stripe from "stripe"; import { nanoId } from "../helpers.ts"; -import { accounts, sessions, users, verificationTokens } from "../schema.ts"; +import { + accounts, + organizationInvites, + organizationMembers, + organizations, + sessions, + users, + verificationTokens, +} from "../schema.ts"; export function DrizzleAdapter(db: MySql2Database): Adapter { return { async createUser(userData: any) { - await db.insert(users).values({ - id: User.UserId.make(nanoId()), - email: userData.email, - emailVerified: userData.emailVerified, - name: userData.name, - image: userData.image, - activeOrganizationId: Organisation.OrganisationId.make(""), + const userId = User.UserId.make(nanoId()); + await db.transaction(async (tx) => { + await tx.insert(users).values({ + id: userId, + email: userData.email, + emailVerified: userData.emailVerified, + name: userData.name, + image: userData.image, + activeOrganizationId: Organisation.OrganisationId.make(""), + }); + + const [pendingInvite] = await tx + .select({ id: organizationInvites.id }) + .from(organizationInvites) + .where( + and( + eq(organizationInvites.invitedEmail, userData.email), + eq(organizationInvites.status, "pending"), + ), + ) + .limit(1); + + if (!pendingInvite) { + const organizationId = Organisation.OrganisationId.make(nanoId()); + + await tx.insert(organizations).values({ + id: organizationId, + ownerId: userId, + name: "My Organization", + }); + + await tx.insert(organizationMembers).values({ + id: nanoId(), + organizationId, + userId, + role: "owner", + }); + + await tx + .update(users) + .set({ + activeOrganizationId: organizationId, + defaultOrgId: organizationId, + }) + .where(eq(users.id, userId)); + } }); + const rows = await db .select() .from(users) - .where(eq(users.email, userData.email)) + .where(eq(users.id, userId)) .limit(1); - const row = rows[0]; + let row = rows[0]; if (!row) throw new Error("User not found"); if (STRIPE_AVAILABLE()) { @@ -80,6 +128,15 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { }), }) .where(eq(users.id, row.id)); + + const [updatedRow] = await db + .select() + .from(users) + .where(eq(users.id, row.id)) + .limit(1); + if (updatedRow) { + row = updatedRow; + } } return row; diff --git a/packages/web-backend/src/Users/UsersOnboarding.ts b/packages/web-backend/src/Users/UsersOnboarding.ts index f4b10a460..272ce8885 100644 --- a/packages/web-backend/src/Users/UsersOnboarding.ts +++ b/packages/web-backend/src/Users/UsersOnboarding.ts @@ -28,6 +28,9 @@ export class UsersOnboarding extends Effect.Service()( .where(Dz.eq(Db.users.id, currentUser.id)), ); + const firstName = data.firstName.trim(); + const lastName = data.lastName?.trim() ?? ""; + yield* db.use((db) => db .update(Db.users) @@ -36,11 +39,31 @@ export class UsersOnboarding extends Effect.Service()( ...user.onboardingSteps, welcome: true, }, - name: data.firstName, - lastName: data.lastName || "", + name: firstName, + lastName, }) .where(Dz.eq(Db.users.id, currentUser.id)), ); + + const activeOrgId = user.activeOrganizationId ?? user.defaultOrgId; + if (activeOrgId && firstName.length > 0) { + const [organization] = yield* db.use((db) => + db + .select({ name: Db.organizations.name }) + .from(Db.organizations) + .where(Dz.eq(Db.organizations.id, activeOrgId)), + ); + + if (organization?.name === "My Organization") { + const personalizedName = `${firstName}'s Organization`; + yield* db.use((db) => + db + .update(Db.organizations) + .set({ name: personalizedName }) + .where(Dz.eq(Db.organizations.id, activeOrgId)), + ); + } + } }), organizationSetup: Effect.fn("Onboarding.organizationSetup")( @@ -61,36 +84,83 @@ export class UsersOnboarding extends Effect.Service()( .where(Dz.eq(Db.users.id, currentUser.id)), ); - const organizationId = Organisation.OrganisationId.make(nanoId()); + const organizationName = + data.organizationName.trim() || data.organizationName; + let organizationId = + user.activeOrganizationId ?? user.defaultOrgId ?? null; yield* db.use((db) => db.transaction(async (tx) => { - await tx.insert(Db.organizations).values({ - id: organizationId, - ownerId: currentUser.id, - name: data.organizationName, - }); - - await tx.insert(Db.organizationMembers).values({ - id: nanoId(), - userId: currentUser.id, - role: "owner", - organizationId, - }); + let resolvedOrgId = organizationId; + + if (resolvedOrgId) { + const [existingOrg] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where(Dz.eq(Db.organizations.id, resolvedOrgId)); + + if (existingOrg) { + await tx + .update(Db.organizations) + .set({ name: organizationName }) + .where(Dz.eq(Db.organizations.id, resolvedOrgId)); + } else { + resolvedOrgId = Organisation.OrganisationId.make(nanoId()); + + await tx.insert(Db.organizations).values({ + id: resolvedOrgId, + ownerId: currentUser.id, + name: organizationName, + }); + + await tx.insert(Db.organizationMembers).values({ + id: nanoId(), + organizationId: resolvedOrgId, + userId: currentUser.id, + role: "owner", + }); + } + } else { + resolvedOrgId = Organisation.OrganisationId.make(nanoId()); + + await tx.insert(Db.organizations).values({ + id: resolvedOrgId, + ownerId: currentUser.id, + name: organizationName, + }); + + await tx.insert(Db.organizationMembers).values({ + id: nanoId(), + organizationId: resolvedOrgId, + userId: currentUser.id, + role: "owner", + }); + } await tx .update(Db.users) .set({ - activeOrganizationId: organizationId, + activeOrganizationId: resolvedOrgId, + defaultOrgId: resolvedOrgId, onboardingSteps: { ...user.onboardingSteps, organizationSetup: true, }, }) .where(Dz.eq(Db.users.id, currentUser.id)); + + organizationId = resolvedOrgId; }), ); + if (!organizationId) { + throw new Error( + "Failed to resolve organization during onboarding", + ); + } + + const finalOrganizationId = organizationId; + if (data.organizationIcon) { const organizationIcon = data.organizationIcon; const uploadEffect = Effect.gen(function* () { @@ -104,7 +174,7 @@ export class UsersOnboarding extends Effect.Service()( const fileExtension = allowedExt.get(contentType); if (!fileExtension) throw new Error("Unsupported icon content type"); - const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`; + const fileKey = `organizations/${finalOrganizationId}/icon-${Date.now()}.${fileExtension}`; const [bucket] = yield* s3Buckets.getBucketAccess( Option.none(), @@ -117,7 +187,7 @@ export class UsersOnboarding extends Effect.Service()( db .update(Db.organizations) .set({ iconUrl }) - .where(Dz.eq(Db.organizations.id, organizationId)), + .where(Dz.eq(Db.organizations.id, finalOrganizationId)), ); }).pipe( Effect.catchAll((error) => @@ -128,7 +198,7 @@ export class UsersOnboarding extends Effect.Service()( yield* uploadEffect; } - return { organizationId }; + return { organizationId: finalOrganizationId }; }, ), From 087f4e73d80016d3a1c4ccd1b517a62f54097dbd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Oct 2025 01:57:04 +0100 Subject: [PATCH 2/2] Improve invite handling --- .../app/(org)/onboarding/[...steps]/page.tsx | 7 ++- apps/web/app/api/invite/accept/route.ts | 36 ++++++++--- packages/database/auth/drizzle-adapter.ts | 62 ++++++++++--------- 3 files changed, 67 insertions(+), 38 deletions(-) diff --git a/apps/web/app/(org)/onboarding/[...steps]/page.tsx b/apps/web/app/(org)/onboarding/[...steps]/page.tsx index 949f15357..a9ffe7d3c 100644 --- a/apps/web/app/(org)/onboarding/[...steps]/page.tsx +++ b/apps/web/app/(org)/onboarding/[...steps]/page.tsx @@ -9,7 +9,12 @@ export default async function OnboardingStepPage({ params, }: { params: Promise<{ - steps: "welcome" | "organization-setup" | "custom-domain" | "invite-team"; + steps: + | "welcome" + | "organization-setup" + | "custom-domain" + | "invite-team" + | "download"; }>; }) { const step = (await params).steps[0]; diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 190c9bf8d..71d216a79 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -6,7 +6,7 @@ import { organizationMembers, users, } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { @@ -46,18 +46,40 @@ export async function POST(request: NextRequest) { ); } - await db().insert(organizationMembers).values({ - id: nanoId(), - organizationId: invite.organizationId, - userId: user.id, - role: invite.role, - }); + const [existingMembership] = await db() + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, invite.organizationId), + eq(organizationMembers.userId, user.id), + ), + ) + .limit(1); + + if (!existingMembership) { + await db().insert(organizationMembers).values({ + id: nanoId(), + organizationId: invite.organizationId, + userId: user.id, + role: invite.role, + }); + } + + const onboardingSteps = { + ...(user.onboardingSteps ?? {}), + organizationSetup: true, + customDomain: true, + inviteTeam: true, + }; await db() .update(users) .set({ thirdPartyStripeSubscriptionId: organizationOwner.stripeSubscriptionId, activeOrganizationId: invite.organizationId, + defaultOrgId: invite.organizationId, + onboardingSteps, }) .where(eq(users.id, user.id)); diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 12214e463..ec3f596a5 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -20,15 +20,6 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { async createUser(userData: any) { const userId = User.UserId.make(nanoId()); await db.transaction(async (tx) => { - await tx.insert(users).values({ - id: userId, - email: userData.email, - emailVerified: userData.emailVerified, - name: userData.name, - image: userData.image, - activeOrganizationId: Organisation.OrganisationId.make(""), - }); - const [pendingInvite] = await tx .select({ id: organizationInvites.id }) .from(organizationInvites) @@ -40,30 +31,41 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { ) .limit(1); - if (!pendingInvite) { - const organizationId = Organisation.OrganisationId.make(nanoId()); + await tx.insert(users).values({ + id: userId, + email: userData.email, + emailVerified: userData.emailVerified, + name: userData.name, + image: userData.image, + activeOrganizationId: Organisation.OrganisationId.make(""), + }); + + if (pendingInvite) { + return; + } - await tx.insert(organizations).values({ - id: organizationId, - ownerId: userId, - name: "My Organization", - }); + const organizationId = Organisation.OrganisationId.make(nanoId()); - await tx.insert(organizationMembers).values({ - id: nanoId(), - organizationId, - userId, - role: "owner", - }); + await tx.insert(organizations).values({ + id: organizationId, + ownerId: userId, + name: "My Organization", + }); - await tx - .update(users) - .set({ - activeOrganizationId: organizationId, - defaultOrgId: organizationId, - }) - .where(eq(users.id, userId)); - } + await tx.insert(organizationMembers).values({ + id: nanoId(), + organizationId, + userId, + role: "owner", + }); + + await tx + .update(users) + .set({ + activeOrganizationId: organizationId, + defaultOrgId: organizationId, + }) + .where(eq(users.id, userId)); }); const rows = await db