Skip to content

Commit a359f0b

Browse files
authored
feat: organization upgrade flow v3 (calcom#25972)
* wip flow * add tests * WIP migrateing view * push back step * fix tests and logic for adding new members to existing teams * few UI fixes * type fixes * fix nits * few UI + re-route fixes * fix teamId when migrating
1 parent 8848efe commit a359f0b

22 files changed

Lines changed: 1747 additions & 79 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { cookies, headers } from "next/headers";
2+
import { redirect } from "next/navigation";
3+
4+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
5+
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
6+
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
7+
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
8+
import { prisma } from "@calcom/prisma";
9+
10+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
11+
12+
import { OrganizationMigrateMembersView } from "~/onboarding/organization/migrate-members/organization-migrate-members-view";
13+
14+
export default async function MigrateMembersPage() {
15+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
16+
17+
if (!session?.user?.id) {
18+
return redirect("/auth/login");
19+
}
20+
21+
const userEmail = session.user.email || "";
22+
const userId = session.user.id;
23+
24+
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
25+
26+
if (!isCompanyEmail(userEmail)) {
27+
return redirect(gettingStartedPath);
28+
}
29+
30+
const userRepository = new UserRepository(prisma);
31+
const isMemberOfOrganization = await userRepository.findIfAMemberOfSomeOrganization({ user: { id: userId } });
32+
33+
if (isMemberOfOrganization) {
34+
return redirect(gettingStartedPath);
35+
}
36+
37+
return <OrganizationMigrateMembersView userEmail={userEmail} />;
38+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { cookies, headers } from "next/headers";
2+
import { redirect } from "next/navigation";
3+
4+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
5+
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
6+
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
7+
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
8+
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
9+
import { prisma } from "@calcom/prisma";
10+
11+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
12+
13+
import { OrganizationMigrateTeamsView } from "~/onboarding/organization/migrate-teams/organization-migrate-teams-view";
14+
15+
type PageProps = {
16+
searchParams: Promise<{ migrate?: string }>;
17+
};
18+
19+
export default async function MigrateTeamsPage({ searchParams }: PageProps) {
20+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
21+
22+
if (!session?.user?.id) {
23+
return redirect("/auth/login");
24+
}
25+
26+
const userEmail = session.user.email || "";
27+
const userId = session.user.id;
28+
29+
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
30+
31+
if (!isCompanyEmail(userEmail)) {
32+
return redirect(gettingStartedPath);
33+
}
34+
35+
const userRepository = new UserRepository(prisma);
36+
const { organizations } = await userRepository.findOrganizations({ userId });
37+
38+
if (organizations.length > 0) {
39+
return redirect(gettingStartedPath);
40+
}
41+
42+
// Check for migrate query param
43+
const params = await searchParams;
44+
const migrateParam = params?.migrate;
45+
46+
// If no migrate param, redirect to teams step
47+
if (migrateParam !== "true") {
48+
return redirect("/onboarding/organization/teams");
49+
}
50+
51+
// Check if user has teams to migrate
52+
const teamRepository = new TeamRepository(prisma);
53+
const ownedTeams = await teamRepository.findOwnedTeamsByUserId({ userId });
54+
55+
// If no teams, redirect to teams step
56+
if (ownedTeams.length === 0) {
57+
return redirect("/onboarding/organization/teams");
58+
}
59+
60+
return <OrganizationMigrateTeamsView userEmail={userEmail} />;
61+
}

apps/web/app/api/organizations/payment-redirect/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { z } from "zod";
55

66
import stripe from "@calcom/features/ee/payments/server/stripe";
77
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
8+
import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
89
import { WEBAPP_URL } from "@calcom/lib/constants";
910
import { HttpError } from "@calcom/lib/http-error";
1011
import { prisma } from "@calcom/prisma";
12+
import { orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
1113

1214
const querySchema = z.object({
1315
session_id: z.string().min(1),
@@ -43,6 +45,19 @@ async function getHandler(req: NextRequest) {
4345

4446
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
4547
if (isOnboardingV3Enabled && organizationOnboardingId) {
48+
// Check if this is a migration flow (user has already completed onboarding)
49+
const onboarding = await OrganizationOnboardingRepository.findById(organizationOnboardingId);
50+
const hasMigratedTeams =
51+
onboarding?.teams &&
52+
orgOnboardingTeamsSchema.parse(onboarding.teams).some((team) => team.isBeingMigrated);
53+
54+
if (hasMigratedTeams) {
55+
// Migration flow - user already completed onboarding, redirect to event-types
56+
const redirectUrl = new URL("/event-types?newOrganizationModal=true", WEBAPP_URL).toString();
57+
return NextResponse.redirect(redirectUrl);
58+
}
59+
60+
// Regular flow - redirect to personal onboarding
4661
params.append("fromTeamOnboarding", "true");
4762
const redirectUrl = new URL(
4863
`/onboarding/personal/settings?${params.toString()}`,

apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx

Lines changed: 115 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { usePathname } from "next/navigation";
66
import { useLocale } from "@calcom/lib/hooks/useLocale";
77
import { trpc } from "@calcom/trpc/react";
88
import { Avatar } from "@calcom/ui/components/avatar";
9+
import { Badge } from "@calcom/ui/components/badge";
10+
import classNames from "@calcom/ui/classNames";
911

1012
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
1113

@@ -26,6 +28,7 @@ type DisplayItem = {
2628
email: string;
2729
team: string;
2830
isReal?: boolean;
31+
isMigrated?: boolean;
2932
};
3033

3134
export const OnboardingInviteBrowserView = ({
@@ -35,8 +38,16 @@ export const OnboardingInviteBrowserView = ({
3538
}: OnboardingInviteBrowserViewProps) => {
3639
const pathname = usePathname();
3740
const { data: user } = trpc.viewer.me.get.useQuery();
38-
const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } =
39-
useOnboardingStore();
41+
const {
42+
teamBrand,
43+
teamInvites,
44+
invites,
45+
teamDetails,
46+
organizationBrand,
47+
organizationDetails,
48+
migratedMembers,
49+
teams,
50+
} = useOnboardingStore();
4051
const { t } = useLocale();
4152

4253
// Animation variants for entry and exit
@@ -83,6 +94,32 @@ export const OnboardingInviteBrowserView = ({
8394
// Filter out empty invites (where email is empty or just whitespace)
8495
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
8596

97+
// Add migrated members if using organization invites
98+
const migratedInvites: Invite[] = [];
99+
if (useOrganizationInvites && migratedMembers.length > 0) {
100+
migratedInvites.push(
101+
...migratedMembers.map((member) => {
102+
// Find team name from teamId
103+
const team = teams.find((t) => t.id === member.teamId);
104+
return {
105+
email: member.email,
106+
team: team?.name || "",
107+
role: "MEMBER" as const,
108+
};
109+
})
110+
);
111+
}
112+
113+
// Combine form invites with migrated members, avoiding duplicates
114+
const allInvites = [...validInvites];
115+
const existingEmails = new Set(validInvites.map((inv) => inv.email.toLowerCase()));
116+
migratedInvites.forEach((migratedInvite) => {
117+
if (!existingEmails.has(migratedInvite.email.toLowerCase())) {
118+
allInvites.push(migratedInvite);
119+
existingEmails.add(migratedInvite.email.toLowerCase());
120+
}
121+
});
122+
86123
// Create empty state items
87124
const emptyStateItem = {
88125
name: t("team_member"),
@@ -95,14 +132,19 @@ export const OnboardingInviteBrowserView = ({
95132
const displayItems: DisplayItem[] = [];
96133
const maxItems = 9;
97134

98-
// Add actual invites first
99-
for (let i = 0; i < validInvites.length && i < maxItems; i++) {
100-
const invite = validInvites[i];
135+
// Create a set of migrated member emails for quick lookup
136+
const migratedEmails = new Set(migratedMembers.map((member) => member.email.toLowerCase()));
137+
138+
// Add all invites (form invites + migrated members)
139+
for (let i = 0; i < allInvites.length && i < maxItems; i++) {
140+
const invite = allInvites[i];
141+
const isMigrated = migratedEmails.has(invite.email.toLowerCase());
101142
displayItems.push({
102143
name: invite.email.split("@")[0] || t("team_member"),
103144
email: invite.email,
104145
team: invite.team || t("team"),
105146
isReal: true,
147+
isMigrated,
106148
});
107149
}
108150

@@ -134,30 +176,65 @@ export const OnboardingInviteBrowserView = ({
134176
ease: "backOut",
135177
}}>
136178
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
137-
<div className="relative p-1">
138-
{/* Banner Image */}
139-
{organizationBrand.banner && (
140-
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
141-
{/* eslint-disable-next-line @next/next/no-img-element */}
142-
<img
143-
src={organizationBrand.banner}
144-
alt={displayName}
145-
className="h-full w-full object-cover"
146-
/>
147-
</div>
148-
)}
179+
{useOrganizationInvites || organizationBrand.banner || avatar ? (
180+
<div className="relative p-1">
181+
{/* Banner Image */}
182+
{useOrganizationInvites && (
183+
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
184+
{organizationBrand.banner ? (
185+
/* eslint-disable-next-line @next/next/no-img-element */
186+
<img
187+
src={organizationBrand.banner}
188+
alt={displayName}
189+
className="h-full w-full object-cover"
190+
/>
191+
) : (
192+
<div className="bg-emphasis h-full w-full" />
193+
)}
194+
</div>
195+
)}
196+
{!useOrganizationInvites && organizationBrand.banner && (
197+
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
198+
{/* eslint-disable-next-line @next/next/no-img-element */}
199+
<img
200+
src={organizationBrand.banner}
201+
alt={displayName}
202+
className="h-full w-full object-cover"
203+
/>
204+
</div>
205+
)}
149206

150-
{/* Organization Avatar - Overlaying the banner */}
151-
{organizationBrand.banner && avatar && (
152-
<div className="absolute -bottom-6 left-4">
153-
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
154-
</div>
155-
)}
156-
</div>
207+
{/* Organization Avatar - Overlaying the banner */}
208+
{useOrganizationInvites && avatar && (
209+
<div className="absolute -bottom-6 left-4">
210+
<Avatar
211+
size="lg"
212+
imageSrc={avatar}
213+
alt={displayName}
214+
className="h-12 w-12 border"
215+
/>
216+
</div>
217+
)}
218+
{!useOrganizationInvites && organizationBrand.banner && avatar && (
219+
<div className="absolute -bottom-6 left-4">
220+
<Avatar
221+
size="lg"
222+
imageSrc={avatar}
223+
alt={displayName}
224+
className="h-12 w-12 border"
225+
/>
226+
</div>
227+
)}
228+
</div>
229+
) : null}
157230

158231
{/* Organization Info */}
159-
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
160-
{!organizationBrand.banner && avatar && (
232+
<div
233+
className={classNames(
234+
`flex flex-col items-start gap-1 px-4 pb-4`,
235+
useOrganizationInvites || organizationBrand.banner || avatar ? "pt-8" : "pt-4"
236+
)}>
237+
{!useOrganizationInvites && !organizationBrand.banner && avatar && (
161238
<Avatar
162239
size="lg"
163240
imageSrc={avatar}
@@ -192,11 +269,18 @@ export const OnboardingInviteBrowserView = ({
192269
{item.email}
193270
</p>
194271
</div>
195-
{item.team && (
196-
<div className="bg-emphasis text-emphasis rounded-md px-2 py-0.5 text-xs">
197-
{item.team}
198-
</div>
199-
)}
272+
<div className="flex flex-col items-center gap-1">
273+
{item.team && !item.isMigrated && (
274+
<div className="bg-emphasis text-emphasis line-clamp-1 max-w-[86px] truncate rounded-md px-2 py-0.5 text-xs">
275+
{item.team}
276+
</div>
277+
)}
278+
{item.isMigrated && (
279+
<Badge variant="green" className="text-xs">
280+
{t("migrating")}
281+
</Badge>
282+
)}
283+
</div>
200284
</div>
201285
</div>
202286
))}

0 commit comments

Comments
 (0)