Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/app/(org)/onboarding/[...steps]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment on lines 11 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Fix params typing and step extraction (string[] vs Promise/union).

Next.js passes params synchronously; catch‑all [...steps] yields string[]. Current type (Promise<{ steps: union }>), then indexing steps[0], is wrong and loses type safety.

Apply:

-export default async function OnboardingStepPage({
-  params,
-}: {
-  params: Promise<{
-    steps:
-      | "welcome"
-      | "organization-setup"
-      | "custom-domain"
-      | "invite-team"
-      | "download";
-  }>;
-}) {
-  const step = (await params).steps[0];
+type OnboardingStep =
+  | "welcome"
+  | "organization-setup"
+  | "custom-domain"
+  | "invite-team"
+  | "download";
+
+export default async function OnboardingStepPage({
+  params,
+}: {
+  params: { steps: string[] };
+}) {
+  const step = (params.steps?.[0] ?? "welcome") as OnboardingStep;

Also applies to: 20-20

🤖 Prompt for AI Agents
In apps/web/app/(org)/onboarding/[...steps]/page.tsx around lines 11-17 (and
also line 20), the params typing is incorrect: Next.js provides params
synchronously and the catch-all "[...steps]" yields string[] (or undefined), not
Promise or a union; change the function signature to accept params: { steps?:
string[] } (or the Next.js PageProps equivalent), then extract the current step
via a safe read like const step = params.steps?.[0] ?? "welcome" and narrow it
to a StepType union (e.g., type Step = "welcome" | "organization-setup" |
"custom-domain" | "invite-team" | "download"; const stepTyped =
(["welcome","organization-setup","custom-domain","invite-team","download"] as
const).includes(step as any) ? step as Step : "welcome";) so you preserve type
safety and handle missing/unknown values; update the usage at line 20
accordingly to use the new stepTyped variable.

}>;
}) {
const step = (await params).steps[0];
Expand Down
36 changes: 29 additions & 7 deletions apps/web/app/api/invite/accept/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
});
}
Comment on lines +49 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Eliminate TOCTOU duplicate membership risk; make insert idempotent and atomic.

The check‑then‑insert can race and create duplicate rows. Wrap accept flow in a DB transaction and enforce a unique constraint on (organizationId, userId) with an upsert.

  • Schema: make (organizationId,userId) unique.

Outside this file (packages/database/schema.ts):

- userIdOrganizationIdIndex: index("user_id_organization_id_idx").on(
+ userIdOrganizationIdIndex: uniqueIndex("user_id_organization_id_uidx").on(
   table.userId,
   table.organizationId,
 ),
  • Route: use upsert to be idempotent (and optionally update role), or at least run inside a transaction.

Within this file:

-    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,
-      });
-    }
+    // within a transaction `tx`, or use db().$primary if not transacting
+    await db()
+      .insert(organizationMembers)
+      .values({
+        id: nanoId(),
+        organizationId: invite.organizationId,
+        userId: user.id,
+        role: invite.role,
+      })
+      .onDuplicateKeyUpdate({ set: { role: invite.role } });

Also consider running the entire accept path in db().transaction(async tx => { ... }) and using tx for all reads/writes to keep it atomic. [Based on learnings]

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/app/api/invite/accept/route.ts around lines 49 to 67, the current
check-then-insert for organization membership can race and create duplicates;
change the flow to run inside a database transaction and perform an idempotent
upsert against a schema-enforced unique constraint on (organizationId, userId).
Update the DB schema (packages/database/schema.ts) to add a unique constraint on
(organizationId, userId), wrap the accept handler in db().transaction(async tx
=> { ... }) and use tx for all selects/inserts/updates, and replace the
conditional insert with an upsert (on conflict / merge) that either does nothing
or updates the role as needed so the operation is atomic and safe from TOCTOU
races.


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));

Expand Down
79 changes: 69 additions & 10 deletions packages/database/auth/drizzle-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,75 @@ 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) => {
const [pendingInvite] = await tx
.select({ id: organizationInvites.id })
.from(organizationInvites)
.where(
and(
eq(organizationInvites.invitedEmail, userData.email),
eq(organizationInvites.status, "pending"),
),
)
.limit(1);

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;
}

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()) {
Expand Down Expand Up @@ -80,6 +130,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;
Expand Down
108 changes: 89 additions & 19 deletions packages/web-backend/src/Users/UsersOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export class UsersOnboarding extends Effect.Service<UsersOnboarding>()(
.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)
Expand All @@ -36,11 +39,31 @@ export class UsersOnboarding extends Effect.Service<UsersOnboarding>()(
...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")(
Expand All @@ -61,36 +84,83 @@ export class UsersOnboarding extends Effect.Service<UsersOnboarding>()(
.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* () {
Expand All @@ -104,7 +174,7 @@ export class UsersOnboarding extends Effect.Service<UsersOnboarding>()(
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(),
Expand All @@ -117,7 +187,7 @@ export class UsersOnboarding extends Effect.Service<UsersOnboarding>()(
db
.update(Db.organizations)
.set({ iconUrl })
.where(Dz.eq(Db.organizations.id, organizationId)),
.where(Dz.eq(Db.organizations.id, finalOrganizationId)),
);
Comment on lines +190 to 191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t persist pre‑signed URLs; they expire. Store the object key and derive URLs on demand.

Saving a signed URL will break later. Persist the key (e.g., fileKey) and generate signed URLs at read time.

Example:

  • Store { iconKey: fileKey } (schema field), and return/generate signed URL to clients when needed.
🤖 Prompt for AI Agents
In packages/web-backend/src/Users/UsersOnboarding.ts around lines 190–191, the
code is persisting a pre-signed URL (which will expire); change the persistence
to save the storage object key (e.g., iconKey or fileKey) instead of the signed
URL, update the DB schema field to hold that key, and remove any code that
writes the signed URL to the record; then generate and return a fresh pre-signed
URL on read/response by calling the storage SDK with the stored key (or provide
a helper that derives signed URLs on demand).

}).pipe(
Effect.catchAll((error) =>
Expand All @@ -128,7 +198,7 @@ export class UsersOnboarding extends Effect.Service<UsersOnboarding>()(
yield* uploadEffect;
}

return { organizationId };
return { organizationId: finalOrganizationId };
},
),

Expand Down