Compare commits

..
Author SHA1 Message Date
github-actions[bot] 8e481f2921 chore: version packages (#24451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-19 08:33:07 -03:00
Dhairyashil ShindeandAnik Dhabal Babu 0e6203ac3c fix: install paypal button team level (#24442)
* fix: install paypal button at team level

* fix: install paypal button at team level

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-19 01:18:31 +05:30
Hariom BalharaandDevin AI 624d6d7001 Add embed-iframe import to error pages (#24475)
Import @calcom/embed-core/src/embed-iframe in both the error page component
and _error.tsx to ensure embed functionality is initialized when users
encounter 500 errors. This allows proper communication with parent frames
when the error page is displayed in an embedded context.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-18 15:39:32 -03:00
an_ifrah24andKeith Williams ec03ab632e fix: make vapid optional to prevent api failure at initialisation (#24469)
* fix: added optional check for VAPID keys

* fix: added extra check for vapid to prevent runtime error

* fix: added consistent logger across the file

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-10-18 18:30:12 +00:00
spandev c81b8145b5 fixed spacing between bold and italic buttons in description of new event type (#24542) 2025-10-18 18:20:57 +00:00
fa35cc5210 chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* NITS

* Add the logic to auto complete admin org when billing enabled

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-10-18 14:13:30 +00:00
Volnei MunhozandDevin AI 1eb6ff3e65 chore: calendar cache optmize (#24533)
* wip

* fix: regenerate Prisma types and fix GoogleCalendarSubscriptionAdapter tests

- Run yarn prisma generate to regenerate Prisma client with missing types (WorkflowType enum)
- Fix addMonthsFromNow helper to properly return the calculated date
- Update GoogleCalendarSubscriptionAdapter to use startOf/endOf day for time ranges
- Update test expectations to use Date objects instead of dayjs objects
- Fix all-day event test to use future date instead of today
- All tests now passing (3599 passed)

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix tests

* parametrize months ahead

* improve names

* Improve based on reviews

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-17 17:20:08 +00:00
fc4ecf500a fix: Apply organization brand colors and theme to member personal events (#24456)
* fix: Apply organization brand colors and theme to member personal events

- Fetch organization brand colors and theme in getEventTypesFromDB
- Override user brand colors/theme with org values for personal events
- Apply to booking confirmation pages, user booking pages, and routing forms
- Follow same pattern as hideBranding for consistency
- Fixes issue where org brand colors only applied to team events

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: Resolve type errors for organization brand color and theme overrides

- Added brandColor, darkBrandColor, theme to ProfileRepository findById organization select
- Updated test mock to include new organization fields
- Ensures consistent organization branding types across all queries
- All type errors related to brand color changes now resolved

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: Remove redundant profile assignment causing type errors

The spread operator already includes profile when it exists.
Explicit assignment causes type conflicts with union types where
profile is not guaranteed to exist in all branches.

Resolves TypeScript error at bookings-single-view.getServerSideProps.tsx:134

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: Use eventTypeRaw.profile for organization brand color access

Changed eventType.profile to eventTypeRaw.profile in profile object construction
to fix TypeScript errors where profile property doesn't exist on transformed
eventType object. The eventTypeRaw object maintains the original profile field
from the database query.

Resolves TypeScript errors at lines 155, 158, 161 in
bookings-single-view.getServerSideProps.tsx

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix type error

* refactor: Extract branding logic into getBrandingForEventType utility

- Create getBrandingForEventType utility function that handles organization
  brand color overrides for both team and personal events
- For team events: org branding → team branding → null
- For personal events: org branding → user branding → null
- Refactor bookings-single-view to use utility function
- Update team booking page to apply organization brand overrides
- Add organization branding fields to ProfileRepository and team queries

Addresses review feedback from @hariombalhara on PR #24456

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Update getBranding.ts

* Change

Set themeBasis to null instead of branding.theme.

* refactor: Simplify branding fallback logic to prevent mixing org/team settings

- Changed getBrandingForEventType to use either parent or team branding entirely
- Simplified team parent branding structure in getServerSideProps
- Prevents inconsistent branding when some properties exist in parent but not others
- Addresses PR review feedback from hariombalhara

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* addressed review

* test: Add comprehensive unit tests for branding utility functions

- Add 20 unit tests covering getBrandingForEventType, getBrandingForUser, and getBrandingForTeam
- Test organization branding override scenarios for both team and personal events
- Test fallback behavior when organization branding is not set
- Test handling of null and optional branding properties
- Verify theme and color inheritance from parent organizations

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: Simplify branding tests to focus on core scenarios

- Reduce from 20 to 8 essential test cases
- Focus on org override and fallback behavior for each utility
- Remove redundant edge case tests while maintaining full coverage

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
2025-10-17 14:03:50 -03:00
Dhairyashil Shinde 9d4522825b chore: fix-vertical-space-between-name-toggle (#24527) 2025-10-17 18:22:46 +05:30
Benny Joo e91bf53d80 refactor: Remove circular deps between @calcom/lib and @calcom/features [2] (#24438)
* move SystemField to features

* migrate workflow service

* merge two tests for team repository

* update imports and migrate team repository

* migrate delegation credential repository

* migrate credential repository

* migrate entityPermissionUtils

* migrate hashedLink service and repository

* migrate membership service

* update imports

* remove file

* migrate buildEventUrlFromBooking

* migrate getAllUserBookings to features

* update imports

* update organizationMock

* migrate slots

* migrate date-ranges to schedules dir

* migrate getAggregatedAvailability

* fix

* refactor

* migrate useCreateEventType hook to features

* migrate assignValueToUser

* migrate validateUsername to auth features

* migrate system field back to lib

* migrate getLabelValueMapFromResponses back to lib

* update imports

* use relative path

* fix type checks

* fix

* fix

* fix tests

* update gh codeowners

* fix

* fix
2025-10-17 06:48:08 -03:00
221 changed files with 5895 additions and 2331 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
This PR adds dry run behaviour for date overrides in the `AvailabilitySettings` atom
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
This PR adds ability to display bookings of a user event for calendar view atom
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
This PR adds an atom to list a user's schedule. Furthermore, there was also another atom introduced which can be used to create a new schedule for a user.
+4 -4
View File
@@ -24,10 +24,10 @@
/packages/embeds/**/* @calcom/Foundation
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
/packages/lib/getUserAvailability.ts @calcom/Foundation
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation
/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation
/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation
/packages/features/schedules/lib/slots.ts @calcom/Foundation
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
@@ -1,10 +1,10 @@
import type { NextApiRequest } from "next";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -13,7 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -0,0 +1,22 @@
import { _generateMetadata } from "app/_utils";
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("resume_onboarding"),
(t) => t("resume_onboarding_description"),
undefined,
undefined,
"/settings/organizations/new/resume"
);
const ServerPage = async () => {
return (
<LayoutWrapper>
<ResumeOnboardingPage />
</LayoutWrapper>
);
};
export default ServerPage;
+2 -2
View File
@@ -6,11 +6,11 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
@@ -12,7 +12,7 @@ import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delega
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import type { Ensure } from "@calcom/types/utils";
+20 -5
View File
@@ -160,13 +160,21 @@ export const AppPage = ({
function refactorMeWithoutEffect() {
const data = appDbQuery.data;
const credentialsCount = data?.credentials.length || 0;
setExistingCredentials(data?.credentials || []);
const credentials = data?.credentials || [];
setExistingCredentials(credentials);
const hasPersonalInstall = credentials.some((c) => !!c.userId && !c.teamId);
const installedTeamIds = new Set<number>();
for (const cred of credentials) {
if (cred.teamId) installedTeamIds.add(cred.teamId);
}
const totalInstalledTargets = (hasPersonalInstall ? 1 : 0) + installedTeamIds.size;
const appInstalledForAllTargets =
availableForTeams && data?.userAdminTeams && data.userAdminTeams.length > 0
? credentialsCount >= data.userAdminTeams.length
: credentialsCount > 0;
? totalInstalledTargets >= data.userAdminTeams.length + 1
: credentials.length > 0;
setAppInstalledForAllTargets(appInstalledForAllTargets);
},
[appDbQuery.data, availableForTeams]
@@ -244,7 +252,14 @@ export const AppPage = ({
loading: isLoading,
};
}
return <InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />;
return (
<InstallAppButtonChild
credentials={availableForTeams ? undefined : appDbQuery.data?.credentials}
paid={paid}
{...props}
/>
);
}}
/>
);
+1
View File
@@ -2,6 +2,7 @@
import React from "react";
import "@calcom/embed-core/src/embed-iframe";
import { HttpError } from "@calcom/lib/http-error";
import { Button } from "@calcom/ui/components/button";
@@ -83,9 +83,12 @@ export const getServerSideProps = async function getServerSideProps(
props: {
isEmbed,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
theme: formWithUserProfile.user.profile?.organization?.theme ?? formWithUserProfile.user.theme,
brandColor:
formWithUserProfile.user.profile?.organization?.brandColor ?? formWithUserProfile.user.brandColor,
darkBrandColor:
formWithUserProfile.user.profile?.organization?.darkBrandColor ??
formWithUserProfile.user.darkBrandColor,
},
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
},
+15 -3
View File
@@ -49,6 +49,13 @@ export const getEventTypesFromDB = async (id: number) => {
profile: {
select: {
organizationId: true,
organization: {
select: {
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
},
teamId: true,
@@ -71,9 +78,15 @@ export const getEventTypesFromDB = async (id: number) => {
slug: true,
name: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
parent: {
select: {
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
createdByOAuthClientId: true,
@@ -116,12 +129,11 @@ export const getEventTypesFromDB = async (id: number) => {
}
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
const isOrgTeamEvent = !!eventType?.team && !!eventType.profile?.organizationId;
return {
isDynamic: false,
...restEventType,
...eventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
metadata,
};
@@ -8,12 +8,12 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
import {
shouldHideBrandingForTeamEvent,
shouldHideBrandingForUserEvent,
} from "@calcom/features/profile/lib/hideBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
+1 -1
View File
@@ -1,4 +1,4 @@
import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization";
import { organizationScenarios } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
import { describe, it, expect, beforeEach, vi } from "vitest";
@@ -4,11 +4,11 @@ import { URLSearchParams } from "url";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildEventUrlFromBooking } from "@calcom/features/bookings/lib/buildEventUrlFromBooking";
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
const querySchema = z.object({
@@ -7,8 +7,9 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
@@ -30,7 +31,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { req, params, query } = context;
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
@@ -133,6 +134,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
const branding = getBrandingForEventType({
eventType: {
team: team.parent ?? team,
users: [],
profile: null,
},
});
return {
props: {
useApiV2,
@@ -153,6 +162,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
: getPlaceholderAvatar(team.logoUrl, team.name),
name,
username: orgSlug ?? null,
...branding,
},
title: eventData.title,
users: eventHostsUserData,
@@ -204,6 +214,9 @@ const getTeamWithEventsData = async (
bannerUrl: true,
logoUrl: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
@@ -214,6 +227,9 @@ const getTeamWithEventsData = async (
logoUrl: true,
name: true,
slug: true,
brandColor: true,
darkBrandColor: true,
theme: true,
eventTypes: {
where: {
slug: meetingSlug,
@@ -6,12 +6,13 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import prisma from "@calcom/prisma";
import { customInputSchema } from "@calcom/prisma/zod-utils";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
@@ -115,7 +116,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
eventTypeRaw.users = eventTypeRaw.hosts?.length
? eventTypeRaw.hosts.map((host) => host.user)
: eventTypeRaw.users;
@@ -150,9 +151,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor || null,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
...getBrandingForEventType({ eventType: eventTypeRaw }),
slug: eventType.team?.slug || eventType.users[0]?.username || null,
};
@@ -24,9 +24,13 @@ import {
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks";
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
import {
SMS_REMINDER_NUMBER_FIELD,
SystemField,
TITLE_FIELD,
} from "@calcom/lib/bookings/SystemField";
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/SystemField";
import { APP_NAME } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
@@ -767,7 +771,7 @@ export default function Success(props: PageProps) {
<Fragment key={field.name}>
<div
className="text-emphasis mt-4 font-medium"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(label),
}}
@@ -1080,7 +1084,7 @@ export default function Success(props: PageProps) {
<span className="underline">
<a
target="_blank"
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations">
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations" rel="noreferrer">
{t("resolve")}
</a>
</span>
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
data-testid={`team.${index}.name`}
label=""
addOnClassname="bg-transparent p-0 border-l-0"
className={index > 0 ? "mb-2" : ""}
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
const session = useSession();
const utils = trpc.useUtils();
const [serverErrorMessage, setServerErrorMessage] = useState("");
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
onSuccess: (data) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
}
},
onError: (error) => {
setServerErrorMessage(t(error.message));
},
});
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
// Single mutation for all flows (billing, self-hosted, admin)
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
onSuccess: async (data) => {
if (data.organization) {
// Invalidate the organizations query to ensure fresh data on the next page
reset({
onboardingId: data.organizationOnboardingId,
});
if (data.checkoutUrl) {
// Billing enabled - redirect to Stripe
window.location.href = data.checkoutUrl;
} else if (data.organizationId) {
// Self-hosted - org already created, redirect to organizations
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
reset();
window.location.href = `${window.location.origin}/settings/organizations/profile`;
} else {
// Unexpected state
setServerErrorMessage("Unexpected response from server");
}
},
onError: (error) => {
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
},
});
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
return {
mutation: mutationToUse,
mutate: mutationToUse.mutate,
isPending: mutationToUse.isPending,
mutation: intentToCreateOrgMutation,
mutate: intentToCreateOrgMutation.mutate,
isPending: intentToCreateOrgMutation.isPending,
errorMessage: serverErrorMessage,
};
};
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
invitedMembers,
logo,
bio,
onboardingId,
name,
slug,
billingPeriod,
seats,
pricePerSeat,
brandColor,
bannerUrl,
} = useOnboardingStore();
const orgCreation = useOrgCreation();
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
placeholder="colleague@company.com"
/>
</div>
<Button type="submit" StartIcon="plus" color="secondary">
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
{t("add")}
</Button>
</form>
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
)}
</div>
<div className="mt-3 mt-6 flex items-center justify-end">
<div className="mt-3 flex items-center justify-end">
<Button
data-testid="publish-button"
onClick={() => {
if (!onboardingId) {
console.error("Org owner email and onboardingId are required", {
orgOwnerEmail,
onboardingId,
});
// Submit ALL data to intentToCreateOrg
if (!name || !slug || !orgOwnerEmail) {
console.error("Required fields missing", { name, slug, orgOwnerEmail });
showToast(t("required_fields_missing"), "error");
return;
}
orgCreation.mutation.mutate({
orgCreation.mutate({
name,
slug,
orgOwnerEmail,
seats,
pricePerSeat,
billingPeriod,
creationSource: "WEBAPP" as const,
logo,
bio,
brandColor,
bannerUrl,
teams,
invitedMembers,
onboardingId,
});
}}
loading={orgCreation.isPending}>
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon } from "@calcom/ui/components/icon";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
const PaymentStatusView = () => {
const { t } = useLocale();
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
const searchParams = useSearchParams();
const paymentStatus = searchParams?.get("paymentStatus");
const paymentError = searchParams?.get("error");
const { useOnboardingStore } = useOnboarding({
step: "status",
});
const { useOnboardingStore } = useOnboarding();
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
const { name } = useOnboardingStore();
@@ -0,0 +1,114 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
import { WizardLayout } from "@calcom/ui/components/layout";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={5}>
{children}
</WizardLayout>
);
};
const ResumeOnboardingView = () => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const onboardingIdParam = searchParams?.get("onboardingId");
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
useEffect(() => {
if (isLoadingOrgOnboarding) {
return;
}
if (!onboardingIdParam) {
router.push("/settings/organizations/new");
return;
}
if (!dbOnboarding) {
return;
}
if (dbOnboarding.isComplete) {
router.push("/settings/organizations");
return;
}
// Load onboarding data into store
reset({
onboardingId: dbOnboarding.id,
name: dbOnboarding.name,
slug: dbOnboarding.slug,
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
billingPeriod: dbOnboarding.billingPeriod,
seats: dbOnboarding.seats,
pricePerSeat: dbOnboarding.pricePerSeat,
logo: dbOnboarding.logo,
bio: dbOnboarding.bio,
brandColor: dbOnboarding.brandColor,
bannerUrl: dbOnboarding.bannerUrl,
});
// Redirect to next step (About page)
router.push("/settings/organizations/new/about");
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
if (isLoadingOrgOnboarding) {
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
<SkeletonText className="h-4 w-1/2" />
</SkeletonContainer>
);
}
if (!onboardingIdParam) {
return (
<Alert
data-testid="error"
severity="error"
title={t("error")}
message={t("no_onboarding_id_provided")}
/>
);
}
if (!dbOnboarding) {
return (
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
);
}
if (dbOnboarding.isComplete) {
return (
<Alert
data-testid="error"
severity="info"
title={t("onboarding_already_complete")}
message={t("onboarding_already_complete_description")}
/>
);
}
// Loading state while redirecting
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
</SkeletonContainer>
);
};
export default ResumeOnboardingView;
@@ -4,8 +4,8 @@ import { useRouter } from "next/navigation";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm";
import { useCreateEventType } from "@calcom/features/eventtypes/hooks/useCreateEventType";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
+1 -1
View File
@@ -60,7 +60,7 @@ vi.mock("@calcom/features/bookings/components/event-meta/Price", () => {
return {};
});
vi.mock("@calcom/lib/SystemField", () => {
vi.mock("@calcom/lib/bookings/SystemField", () => {
return {};
});
@@ -20,7 +20,14 @@ function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof
theme: "dark",
brandColor: "red",
darkBrandColor: "black",
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
organization: {
requestedSlug: "slug",
slug: "slug",
id: 1,
brandColor: null,
darkBrandColor: null,
theme: null,
},
allowSEOIndexing: true,
username: "john",
},
+1
View File
@@ -7,6 +7,7 @@ import type { ErrorProps } from "next/error";
import NextError from "next/error";
import React from "react";
import "@calcom/embed-core/src/embed-iframe";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -0,0 +1,451 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
/**
* Utility function to complete the organization onboarding wizard steps
* (about, add-teams, onboard-members)
*/
async function completeOnboardingWizard(
page: Page,
options: {
aboutText?: string;
teams?: string[];
members?: string[];
} = {}
) {
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
// Step 1: Complete "About" step
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill(aboutText);
await page.locator("button[type=submit]").click();
// Step 2: Complete "Add Teams" step
await page.waitForURL("**/settings/organizations/new/add-teams");
for (let i = 0; i < teams.length; i++) {
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
if (i < teams.length - 1) {
await page.getByTestId("add_a_team").click();
}
}
await page.locator("button[type=submit]").click();
// Step 3: Complete "Onboard Members" step
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Add members if provided
for (const memberEmail of members) {
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
}
// Click publish and wait for response
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.getByTestId("publish-button").click();
const response = await responsePromise;
const responseData = await response.json();
// TRPC batch response is an array with one element
const result = responseData[0].result.data.json;
return result;
}
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
test.describe("Admin Handover Flow (Billing Disabled)", () => {
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
// Setup: Create admin and future owner
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "orgowner",
domain: "example.com",
});
const ownerUser = await users.create({
username: "orgowner",
email: ownerEmail,
});
const orgName = "Test Organization";
const orgSlug = `test-org-${uuid()}`.toLowerCase();
// Step 1: Admin fills form
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
// Submit and wait for intentToCreateOrg
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
// Step 2: Verify admin is on handover page
await page.waitForURL("**/settings/organizations/new/handover");
const onboardingUrlElement = page.getByTestId("onboarding-url");
await expect(onboardingUrlElement).toBeVisible();
const onboardingUrl = await onboardingUrlElement.textContent();
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
// Extract onboardingId from URL
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
expect(onboardingIdMatch).toBeTruthy();
const onboardingId = onboardingIdMatch?.[1];
// Step 3: Switch to owner user
await page.context().clearCookies();
await ownerUser.apiLogin();
// Step 4: Owner opens resume URL and completes the onboarding wizard
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
const result = await completeOnboardingWizard(page, {
aboutText: "This is our test organization",
teams: ["Engineering", "Marketing"],
members: [memberEmail],
});
// Verify the organization was created successfully in the API response
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const orgName = "Admin Org";
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Fill form with admin's own email
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("20");
}
// Submit - admin creating for self goes through wizard
await page.locator("button[type=submit]").click();
// Admin creating for self goes through the onboarding wizard
const result = await completeOnboardingWizard(page, {
aboutText: "Admin's organization",
teams: ["Operations"],
members: [],
});
// Verify the organization was created successfully
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin handover URL structure is correct", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "testowner",
domain: "example.com",
});
await users.create({
username: "testowner",
email: ownerEmail,
});
const orgName = "Handover Test";
const orgSlug = `handover-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.locator("button[type=submit]").click();
// Wait for the response
await responsePromise;
// Wait for redirect to handover page
await page.waitForURL("**/settings/organizations/new/handover");
// Wait for handover page elements to be visible (with longer timeout for store to populate)
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
// Verify URL format
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
// Verify copy button is clickable (toast message may vary by locale)
await page.getByTestId("copy-onboarding-url").click();
});
});
test.describe("Non-Admin User Flow", () => {
test("Regular user creates org through full wizard", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "regularuser",
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
});
await user.apiLogin();
const orgName = "User Organization";
const orgSlug = `user-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Verify user email is pre-filled and disabled
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify billing fields are NOT visible to regular users
if (IS_TEAM_BILLING_ENABLED) {
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
// Verify "Upgrade to Organizations" UI is shown
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
}
// Fill form
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Verify navigated to /about step
await page.waitForURL("**/settings/organizations/new/about");
// Complete about step
await page.locator('textarea[name="about"]').fill("User's organization description");
await page.locator("button[type=submit]").click();
// Complete add-teams step
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team Alpha");
await page.locator("button[type=submit]").click();
// Complete onboard-members step
await page.waitForURL("**/settings/organizations/new/onboard-members");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.getByTestId("publish-button").click(),
]);
// In billing enabled mode, should redirect to Stripe or payment
// In billing disabled mode, org created immediately
if (IS_TEAM_BILLING_ENABLED) {
// Would redirect to Stripe checkout
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
} else {
// After org creation, session update causes redirect to profile page
// Wait for it to load, then navigate to organizations
await page.waitForURL("**/settings/my-account/profile");
await page.goto("/settings/organizations");
await page.waitForURL(/\/settings\/organizations/);
}
});
test("Regular user - email field is disabled", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "testuser",
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Verify email field state
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeVisible();
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify name and slug are derived from email domain
const nameInput = page.locator("input[name=name]");
const slugInput = page.locator("input[name=slug]");
// Name should be capitalized version of domain
const nameValue = await nameInput.inputValue();
expect(nameValue).toBeTruthy();
expect(nameValue).toMatch(/company/i);
// Slug should be domain-based
const slugValue = await slugInput.inputValue();
expect(slugValue).toBeTruthy();
expect(slugValue).toContain("company");
});
});
test.describe("Form Validation", () => {
test("Admin form - required fields validation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Submit empty form
await page.locator("button[type=submit]").click();
// Should show validation errors
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
});
test("Slug already taken shows error", async ({ page, users, orgs }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
// Create an existing org with a specific slug
const existingSlug = `existing-${uuid()}`.toLowerCase();
await orgs.create({
name: "Existing Org",
slug: existingSlug,
});
await page.goto("/settings/organizations/new");
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
await users.create({ username: "owner", email: ownerEmail });
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill("New Org");
await page.locator("input[name=slug]").fill(existingSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("10");
}
await page.locator("button[type=submit]").click();
// Should show slug taken error
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
});
});
test.describe("Navigation Between Steps", () => {
test("User can navigate through wizard steps", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "wizarduser",
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
const orgName = "Wizard Test";
const orgSlug = `wizard-${uuid()}`.toLowerCase();
// Step 1
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Step 2: About
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill("Wizard test org");
await page.locator("button[type=submit]").click();
// Step 3: Add teams
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team 1");
await page.locator("button[type=submit]").click();
// Step 4: Onboard members
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Verify wizard shows correct step numbers
// The WizardLayout component shows currentStep/maxSteps
// Just verify we reached the last step
await expect(page.getByTestId("publish-button")).toBeVisible();
});
});
test.describe("Billing Field Visibility", () => {
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Verify billing fields are visible for admin
await expect(page.locator("input[name=seats]")).toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
await expect(page.locator("#billingPeriod")).toBeVisible();
// Verify toggle between monthly/annual
await expect(page.getByText("Monthly")).toBeVisible();
await expect(page.getByText("Annually")).toBeVisible();
});
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "pricinguser",
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Should NOT see admin billing fields
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
await expect(page.locator("#billingPeriod")).not.toBeVisible();
// Should see upgrade/pricing UI
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
await expect(page.getByText(/organization/i)).toBeVisible();
});
});
});
@@ -1,475 +0,0 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { Messages } from "mailhog";
import path from "path";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import type { createEmailsFixture } from "../fixtures/emails";
import { test } from "../lib/fixtures";
import { fillStripeTestCheckout } from "../lib/testUtils";
import { getEmailsReceivedByUser } from "../lib/testUtils";
async function expectEmailWithSubject(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
const allEmails = (receivedEmails as Messages).items;
const email = allEmails.find((email) => email.subject === subject);
if (!email) {
throw new Error(`Email with subject ${subject} not found`);
}
const dom = new JSDOM(email.html);
return dom;
}
export async function expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
orgSlug: string;
}) {
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
const document = dom?.window?.document;
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
return dom;
}
async function expectOrganizationCreationEmailToBeSentWithLinks({
page,
emails,
userEmail,
oldUsername,
newUsername,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
oldUsername: string;
newUsername: string;
orgSlug: string;
}) {
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
});
const document = dom?.window.document;
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
if (!links) {
throw new Error(`data-testid="organization-link-info doesn't have links`);
}
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
}
export async function expectEmailVerificationEmailToBeSent(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string
) {
const subject = "Cal.com: Verify your account";
return expectEmailWithSubject(page, emails, userEmail, subject);
}
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsernamePrefix = "owner";
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsernamePrefix,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsernamePrefix,
email: orgOwnerEmail,
role: "ADMIN",
});
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
const orgName = capitalize(`${orgOwnerUser.username}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
await test.step("Handover", async () => {
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
expect(onboardingUrl).toContain("settings/organizations/new");
});
});
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsername = `owner`;
const orgName = capitalize(`${orgOwnerUsername}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail: orgOwnerEmail,
orgSlug,
});
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
});
test("User can create and upgrade a org", async ({ page, users, emails }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsername,
email: targetOrgEmail,
});
await orgOwnerUser.apiLogin();
const orgName = capitalize(`${orgOwnerUsername}`);
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgOwnerUsername);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("add_a_team").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
});
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
page,
users,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const numberOfTeams = 3;
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create(
{
username: orgOwnerUsername,
email: targetOrgEmail,
},
{ hasTeam: true, numberOfTeams }
);
await orgOwnerUser.apiLogin();
await page.goto("/teams");
await test.step("Has org self serve banner", async () => {
// These values are inferred due to an existing user being signed
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
await expect(selfServeButtonLocator).toBeVisible();
await selfServeButtonLocator.click();
await page.waitForURL("/settings/organizations/new");
});
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
const slugLocator = await page.locator("input[name=slug]");
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await slugLocator.inputValue()).toBe("example");
await slugLocator.fill(`example-${stringUUID}`);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Move existing teams to org", async () => {
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
const foundCheckboxes = page.locator('input[type="checkbox"]');
const count = await foundCheckboxes.count();
for (let i = 0; i < count; i++) {
const checkbox = foundCheckboxes.nth(i);
await checkbox.click();
}
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("new-team-dialog-button").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
// eslint-disable-next-line playwright/no-skipped-test
await page.goto("/teams");
const teamListItems = await page.getByTestId("team-list-item-link").all();
// Number of teams migrated + the two created in the create teams step
expect(teamListItems.length).toBe(numberOfTeams + 2);
});
});
});
async function fillAndSubmitFirstStepAsAdmin(
page: Page,
targetOrgEmail: string,
orgName: string,
orgSlug: string
) {
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
// Since we are admin fill in this information instead of deriving it
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// Fill in seat information
await page.locator("input[name=seats]").fill("30");
await page.locator("input[name=pricePerSeat]").fill("30");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
}
@@ -3299,6 +3299,12 @@
"organization_onboarding_already_completed": "Organization onboarding already completed",
"organization_onboarding_already_exists": "Organization onboarding already exists",
"organization_onboarding_not_found": "Organization onboarding not found",
"no_onboarding_id_provided": "No onboarding ID provided",
"onboarding_not_found": "Onboarding not found",
"onboarding_already_complete": "Onboarding already complete",
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
"resume_onboarding": "Resume Onboarding",
"resume_onboarding_description": "Continue with your organization onboarding process",
"payment_successful": "Payment successful!",
"handover_onboarding_page_title": "Handover onboarding",
"handover_onboarding_page_description": "Handover onboarding to the user",
@@ -4,14 +4,15 @@ import { encode } from "querystring";
import type { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { getBrandingForUser } from "@calcom/features/profile/lib/getBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { prisma } from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
@@ -33,6 +34,9 @@ type UserPageProps = {
requestedSlug: string | null;
slug: string | null;
id: number | null;
brandColor: string | null;
darkBrandColor: string | null;
theme: string | null;
} | null;
allowSEOIndexing: boolean;
username: string | null;
@@ -130,15 +134,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
const branding = getBrandingForUser({ user });
const profile = {
name: user.name || user.username || "",
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
theme: branding.theme,
brandColor: branding.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
darkBrandColor:
branding.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: user.profile.organization,
+1 -1
View File
@@ -1,5 +1,5 @@
import { HttpError } from "@calcom/lib/http-error";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
@@ -1,6 +1,6 @@
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
@@ -1,7 +1,7 @@
import type z from "zod";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
@@ -4,8 +4,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata";
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { SMSLockState, RRTimestampBasis } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
@@ -28,7 +28,7 @@ vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository",
}));
// Mock DelegationCredentialRepository
vi.mock("@calcom/lib/server/repository/delegationCredential", () => ({
vi.mock("@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository", () => ({
DelegationCredentialRepository: {
findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey: vi.fn(),
findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey: vi.fn(),
+3 -3
View File
@@ -10,9 +10,9 @@ import {
} from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { SelectedCalendar } from "@calcom/prisma/client";
@@ -1,7 +1,7 @@
import { Dub } from "dub-package";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -14,7 +14,7 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
+1 -1
View File
@@ -7,8 +7,8 @@ import type {
SimplePublicObjectInput,
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
import { getLocation } from "@calcom/lib/CalEventParser";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -1,8 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import {
entityPrismaWhereClause,
canEditEntity,
} from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import { sanitizeValue } from "@calcom/lib/csvUtils";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import prisma from "@calcom/prisma";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
@@ -3,9 +3,9 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import {
WebhookTriggerEvents,
WorkflowTriggerEvents,
@@ -37,7 +37,7 @@ vi.mock("@calcom/features/tasker", () => {
});
// Mock workflow dependencies
vi.mock("@calcom/lib/server/service/workflows", () => ({
vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService", () => ({
WorkflowService: {
getAllWorkflowsFromRoutingForm: vi.fn(() => Promise.resolve([])),
scheduleFormWorkflows: vi.fn(() => Promise.resolve()),
@@ -6,7 +6,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -1,6 +1,6 @@
import type { z } from "zod";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { canCreateEntity, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import { canCreateEntity, canEditEntity } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import prisma from "@calcom/prisma";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import logger from "@calcom/lib/logger";
import { routingFormIncompleteBookingDataSchema } from "../../zod";
@@ -1,4 +1,4 @@
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { AttributeRoutingConfig } from "../../routing-forms/types/types";
import SalesforceCRMService from "./CrmService";
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import {
@@ -18,7 +18,7 @@ import { validateAuthentication, buildReturnUrl } from "../portal";
// Mock dependencies
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/lib/server/repository/team");
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
vi.mock("../../lib/customer");
vi.mock("../../lib/server");
vi.mock("../../lib/subscriptions");
@@ -4,7 +4,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import stripe from "../../server";
@@ -1,4 +1,4 @@
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import type { BillingPortalService } from "../base/BillingPortalService";
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
@@ -32,7 +32,7 @@ import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
@@ -6,6 +6,7 @@ import { getLocaleFromRequest } from "@calcom/features/auth/lib/getLocaleFromReq
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
@@ -15,7 +16,6 @@ import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import type { CustomNextApiHandler } from "@calcom/lib/server/username";
import { usernameHandler } from "@calcom/lib/server/username";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { CreationSource } from "@calcom/prisma/enums";
import { IdentityProvider } from "@calcom/prisma/enums";
@@ -3,12 +3,12 @@ import { NextResponse } from "next/server";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
+1 -1
View File
@@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/features/auth/signup/utils/validateUsername";
import { HttpError } from "@calcom/lib/http-error";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
@@ -1,4 +1,4 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { IntervalTree, ContainmentSearchAlgorithm, createIntervalNodes } from "@calcom/lib/intervalTree";
/**
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import dayjs from "@calcom/dayjs";
import type { DateRange } from "@calcom/lib/date-ranges";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { mergeOverlappingDateRanges } from "./mergeOverlappingDateRanges";
@@ -1,4 +1,4 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
export function mergeOverlappingDateRanges(dateRanges: DateRange[]) {
dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
@@ -1,10 +1,10 @@
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { intersect } from "@calcom/features/schedules/lib/date-ranges";
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
import type { DateRange } from "@calcom/lib/date-ranges";
import { intersect } from "@calcom/lib/date-ranges";
import { SchedulingType } from "@calcom/prisma/enums";
import { filterRedundantDateRanges } from "./getAggregatedAvailability/date-range-utils/filterRedundantDateRanges";
import { mergeOverlappingDateRanges } from "./getAggregatedAvailability/date-range-utils/mergeOverlappingDateRanges";
import { filterRedundantDateRanges } from "./date-range-utils/filterRedundantDateRanges";
import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges";
function uniqueAndSortedDateRanges(ranges: DateRange[]): DateRange[] {
const seen = new Set<string>();
@@ -12,9 +12,9 @@ import {
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-ranges";
import { getWorkingHours } from "@calcom/lib/availability";
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { stringToDayjsZod } from "@calcom/lib/dayjs";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
@@ -641,9 +641,9 @@ export class UserAvailabilityService {
// you can obtain that from user availability defined outside of here
fromUser: { id: user.id, displayName: user.name },
// optional chaining destructuring toUser
toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: !!reason ? reason.reason : null,
emoji: !!reason ? reason.emoji : null,
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: reason ? reason.reason : null,
emoji: reason ? reason.emoji : null,
};
}
@@ -8,7 +8,7 @@ import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocati
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
import { SystemField } from "@calcom/lib/SystemField";
import { SystemField } from "@calcom/lib/bookings/SystemField";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -24,7 +24,7 @@ import {
getPiiFreeCalendarEvent,
} from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { prisma } from "@calcom/prisma";
import type { DestinationCalendar, BookingReference } from "@calcom/prisma/client";
import { createdEventSchema } from "@calcom/prisma/zod-utils";
@@ -1,8 +1,7 @@
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import logger from "../logger";
type BookingEventType = {
slug: string;
team: {
@@ -3,7 +3,7 @@ import type { z } from "zod";
import {
SMS_REMINDER_NUMBER_FIELD,
CAL_AI_AGENT_PHONE_NUMBER_FIELD,
} from "@calcom/lib/SystemField";
} from "@calcom/lib/bookings/SystemField";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
@@ -2,7 +2,8 @@ import { isValidPhoneNumber } from "libphonenumber-js";
import z from "zod";
import type { ALL_VIEWS } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema, fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
@@ -1,6 +1,6 @@
import type z from "zod";
import { SystemField } from "@calcom/lib/SystemField";
import { SystemField } from "@calcom/lib/bookings/SystemField";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
@@ -3,7 +3,7 @@ import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
import { mergeOverlappingRanges } from "@calcom/lib/date-ranges";
import { mergeOverlappingRanges } from "@calcom/features/schedules/lib/date-ranges";
import logger from "@calcom/lib/logger";
import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -6,7 +6,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Prisma } from "@calcom/prisma/client";
import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
@@ -18,7 +18,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
@@ -70,8 +70,8 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaAttributeRepository as AttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import type { HostRepository } from "@calcom/lib/server/repository/host";
import type { PrismaOOORepository as OooRepository } from "@calcom/lib/server/repository/ooo";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import type { DestinationCalendar, Prisma, User, AssignmentReasonEnum } from "@calcom/prisma/client";
@@ -3,7 +3,7 @@ import type { Logger } from "tslog";
import dayjs from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import { buildDateRanges } from "@calcom/lib/date-ranges";
import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
import { ErrorCode } from "@calcom/lib/errorCodes";
@@ -4,7 +4,7 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -6,9 +6,9 @@ import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { handleNoShowFee } from "./handleNoShowFee";
@@ -30,7 +30,7 @@ vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/lib/server/repository/credential", () => ({
vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => ({
CredentialRepository: {
findPaymentCredentialByAppIdAndUserIdOrTeamId: vi.fn(),
findPaymentCredentialByAppIdAndTeamId: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => (
},
}));
vi.mock("@calcom/lib/server/repository/team", () => ({
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
TeamRepository: vi.fn().mockImplementation(() => ({
findParentOrganizationByTeamId: vi.fn(),
})),
@@ -7,9 +7,9 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -1,7 +1,7 @@
import dayjs from "@calcom/dayjs";
import { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
import { subtract } from "@calcom/lib/date-ranges";
import { subtract } from "@calcom/features/schedules/lib/date-ranges";
import { stringToDayjs } from "@calcom/lib/dayjs";
import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
@@ -3,6 +3,7 @@ import { v4 as uuid } from "uuid";
import { CalendarAuth } from "@calcom/app-store/googlecalendar/lib/CalendarAuth";
import dayjs from "@calcom/dayjs";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
@@ -117,12 +118,12 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP
};
if (!syncToken) {
const now = dayjs();
const now = dayjs().startOf("day");
// first sync or unsync (3 months)
const threeMonths = now.add(3, "month");
const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day");
const timeMinISO = now.toISOString();
const timeMaxISO = threeMonths.toISOString();
const timeMaxISO = monthsAhead.toISOString();
params.timeMin = timeMinISO;
params.timeMax = timeMaxISO;
} else {
@@ -151,8 +152,21 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP
}
private parseEvents(events: calendar_v3.Schema$Event[]): CalendarSubscriptionEventItem[] {
const now = dayjs().startOf("day");
const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day");
function filterEventsWithoutId(event: calendar_v3.Schema$Event) {
return typeof event.id === "string" && !!event.id;
}
function filterEventsByDateRange(event: calendar_v3.Schema$Event) {
const start = dayjs(event.start?.dateTime || event.start?.date);
return !start.isBefore(now) && !start.isAfter(monthsAhead);
}
return events
.filter((event) => typeof event.id === "string" && !!event.id)
.filter(filterEventsWithoutId)
.filter(filterEventsByDateRange)
.map((event) => {
// empty or opaque is busy
const busy = !event.transparency || event.transparency === "opaque";
@@ -2,11 +2,25 @@ import "../__mocks__/CalendarAuth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter";
const addMonthsFromNow = (months: number) => {
const date = dayjs();
return date.add(months, "months").startOf("day");
};
const today = dayjs().startOf("day");
const oneWeekFromNow = today.add(7, "days");
const eventEndTime = oneWeekFromNow.add(1, "hours");
const channelExpirationDate = new Date(Date.now() + 86400000);
vi.mock("uuid", () => ({
v4: vi.fn().mockReturnValue("test-uuid"),
}));
@@ -30,16 +44,16 @@ const mockSelectedCalendar: SelectedCalendar = {
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
createdAt: today.toDate(),
updatedAt: today.toDate(),
channelId: "test-channel-id",
channelKind: "web_hook",
channelResourceId: "test-resource-id",
channelResourceUri: "test-resource-uri",
channelExpiration: new Date(Date.now() + 86400000),
syncSubscribedAt: new Date(),
channelExpiration: channelExpirationDate,
syncSubscribedAt: today.toDate(),
syncToken: "test-sync-token",
syncedAt: new Date(),
syncedAt: today.toDate(),
syncErrorAt: null,
syncErrorCount: 0,
};
@@ -176,7 +190,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: "1640995200000",
expiration: String(channelExpirationDate.getTime()),
},
};
@@ -202,17 +216,18 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: new Date(1640995200000),
expiration: channelExpirationDate,
});
});
test("should handle expiration as ISO string", async () => {
const mockExpirationDate = new Date(Date.now() + 10000000);
const mockWatchResponse = {
data: {
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: "2023-12-01T10:00:00Z",
expiration: mockExpirationDate.toISOString(),
},
};
@@ -220,7 +235,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
const result = await adapter.subscribe(mockSelectedCalendar, mockCredential);
expect(result.expiration).toEqual(new Date("2023-12-01T10:00:00Z"));
expect(result.expiration).toEqual(mockExpirationDate);
});
test("should handle missing expiration", async () => {
@@ -265,6 +280,19 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
});
describe("fetchEvents", () => {
const commonEventData = {
iCalUID: "event-1@cal.com",
summary: "Test Event",
description: "Test Description",
location: "Test Location",
status: "confirmed",
transparency: "opaque",
kind: "calendar#event",
etag: "test-etag",
created: today.toISOString(),
updated: today.toISOString(),
};
test("should fetch events with sync token", async () => {
const mockEventsResponse = {
data: {
@@ -272,23 +300,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
summary: "Test Event",
description: "Test Description",
location: "Test Location",
...commonEventData,
start: {
dateTime: "2023-12-01T10:00:00Z",
dateTime: oneWeekFromNow.toISOString(),
timeZone: "UTC",
},
end: {
dateTime: "2023-12-01T11:00:00Z",
dateTime: eventEndTime.toISOString(),
},
status: "confirmed",
transparency: "opaque",
kind: "calendar#event",
etag: "test-etag",
created: "2023-12-01T09:00:00Z",
updated: "2023-12-01T09:30:00Z",
},
],
},
@@ -312,8 +331,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-1",
iCalUID: "event-1@cal.com",
start: new Date("2023-12-01T10:00:00Z"),
end: new Date("2023-12-01T11:00:00Z"),
start: oneWeekFromNow.toDate(),
end: eventEndTime.toDate(),
busy: true,
summary: "Test Event",
description: "Test Description",
@@ -325,14 +344,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
timeZone: "UTC",
recurringEventId: null,
originalStartDate: null,
createdAt: new Date("2023-12-01T09:00:00Z"),
updatedAt: new Date("2023-12-01T09:30:00Z"),
createdAt: today.toDate(),
updatedAt: today.toDate(),
},
],
});
});
test("should fetch events without sync token (initial sync)", async () => {
test("should fetch events without sync token (initial sync) with a 3-month range", async () => {
const calendarWithoutSyncToken = {
...mockSelectedCalendar,
syncToken: null,
@@ -349,34 +368,37 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
const result = await adapter.fetchEvents(calendarWithoutSyncToken, mockCredential);
const expectedTimeMin = today.startOf("day").toISOString();
const expectedTimeMax = addMonthsFromNow(3).endOf("day").toISOString();
expect(mockClient.events.list).toHaveBeenCalledWith({
calendarId: "test@example.com",
pageToken: undefined,
singleEvents: true,
timeMin: expect.any(String),
timeMax: expect.any(String),
timeMin: expectedTimeMin,
timeMax: expectedTimeMax,
});
expect(result.syncToken).toBe("initial-sync-token");
});
test("should handle all-day events", async () => {
const allDayStart = oneWeekFromNow.startOf("day");
const allDayEnd = oneWeekFromNow.endOf("day");
const mockEventsResponse = {
data: {
nextSyncToken: "new-sync-token",
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
summary: "All Day Event",
...commonEventData,
start: {
date: "2023-12-01",
date: allDayStart.toISOString().split("T")[0],
},
end: {
date: "2023-12-02",
date: allDayEnd.toISOString().split("T")[0],
},
status: "confirmed",
transparency: "opaque",
},
],
},
@@ -387,8 +409,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
expect(result.items[0].isAllDay).toBe(true);
expect(result.items[0].start).toEqual(new Date("2023-12-01"));
expect(result.items[0].end).toEqual(new Date("2023-12-02"));
expect(result.items[0].start).toEqual(new Date(allDayStart.toISOString().split("T")[0]));
expect(result.items[0].end).toEqual(new Date(allDayEnd.toISOString().split("T")[0]));
});
test("should handle free events (transparent)", async () => {
@@ -398,15 +420,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
...commonEventData,
summary: "Free Event",
start: {
dateTime: "2023-12-01T10:00:00Z",
dateTime: oneWeekFromNow.toISOString(),
},
end: {
dateTime: "2023-12-01T11:00:00Z",
dateTime: eventEndTime.toISOString(),
},
status: "confirmed",
transparency: "transparent",
},
],
@@ -421,6 +442,9 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
});
test("should handle pagination", async () => {
const date2 = new Date(oneWeekFromNow.toDate());
date2.setDate(date2.getDate() + 1);
const mockEventsResponse1 = {
data: {
nextPageToken: "page-2",
@@ -428,8 +452,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-1",
summary: "Event 1",
start: { dateTime: "2023-12-01T10:00:00Z" },
end: { dateTime: "2023-12-01T11:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
],
},
@@ -442,8 +466,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-2",
summary: "Event 2",
start: { dateTime: "2023-12-01T12:00:00Z" },
end: { dateTime: "2023-12-01T13:00:00Z" },
start: { dateTime: date2.toISOString() },
end: { dateTime: new Date(date2.getTime() + 3600000).toISOString() },
},
],
},
@@ -468,19 +492,19 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-1",
summary: "Valid Event",
start: { dateTime: "2023-12-01T10:00:00Z" },
end: { dateTime: "2023-12-01T11:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
{
summary: "Invalid Event",
start: { dateTime: "2023-12-01T12:00:00Z" },
end: { dateTime: "2023-12-01T13:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
{
id: "",
summary: "Empty ID Event",
start: { dateTime: "2023-12-01T14:00:00Z" },
end: { dateTime: "2023-12-01T15:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
],
},
@@ -83,9 +83,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito
async deleteStale() {
return this.prismaClient.calendarCacheEvent.deleteMany({
where: {
end: {
lte: new Date(),
},
end: { lte: new Date() },
},
});
}
@@ -9,6 +9,7 @@ const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] });
* Service to handle calendar cache
*/
export class CalendarCacheEventService {
static MONTHS_AHEAD: number = 3;
constructor(
private deps: {
calendarCacheEventRepository: ICalendarCacheEventRepository;
@@ -239,9 +239,7 @@ describe("CalendarCacheEventRepository", () => {
expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({
where: {
end: {
lte: expect.any(Date),
},
end: { lte: expect.any(Date) },
},
});
});
@@ -284,7 +284,7 @@ describe("CalendarCacheEventService", () => {
description: null,
location: null,
status: "confirmed",
isAllDay: undefined,
isAllDay: false,
timeZone: null,
recurringEventId: null,
originalStartDate: null,
@@ -306,7 +306,7 @@ describe("CalendarCacheEventService", () => {
summary: null,
description: null,
location: null,
isAllDay: undefined,
isAllDay: false,
timeZone: null,
originalStartTime: null,
recurringEventId: null,
@@ -346,9 +346,8 @@ describe("CalendarCacheEventService", () => {
expect(CalendarCacheEventService.isCalendarTypeSupported("unknown_calendar")).toBe(false);
});
test("should return false for null or undefined", () => {
test("should return false for null", () => {
expect(CalendarCacheEventService.isCalendarTypeSupported(null)).toBe(false);
expect(CalendarCacheEventService.isCalendarTypeSupported(undefined)).toBe(false);
});
});
});
@@ -6,7 +6,7 @@ import {
import { describe, test, expect, beforeEach } from "vitest";
import { PrismaAppRepository } from "@calcom/features/apps/repository/PrismaAppRepository";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
@@ -1,11 +1,10 @@
import { buildNonDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { Prisma, PrismaClient } from "@calcom/prisma/client";
import { safeCredentialSelect } from "@calcom/prisma/selects/credential";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { buildNonDelegationCredential } from "../../delegationCredential";
const log = logger.getSubLogger({ prefix: ["CredentialRepository"] });
type CredentialCreateInput = {
@@ -1,11 +1,11 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { encryptServiceAccountKey } from "@calcom/lib/server/serviceAccountKey";
import { DelegationCredentialRepository } from "../delegationCredential";
import { DelegationCredentialRepository } from "./DelegationCredentialRepository";
vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository", () => ({
OrganizationRepository: {
+1 -1
View File
@@ -1,5 +1,5 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { createModule } from "../di";
@@ -3,11 +3,13 @@ import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { prisma } from "@calcom/prisma";
import type { SWHMap } from "./__handler";
@@ -92,10 +94,31 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
};
}
const { organization } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId,
paymentSubscriptionItemId,
// Get the user who created the onboarding (for service instantiation)
const userRepo = new UserRepository(prisma);
const creator = organizationOnboarding.createdById
? await userRepo.findById({ id: organizationOnboarding.createdById })
: null;
// Create a minimal user context for the service
// If no creator, use a system user context (webhook is system-initiated)
const userContext = creator
? {
id: creator.id,
email: creator.email,
role: "ADMIN" as const,
name: creator.name || undefined,
}
: {
id: 0, // System user
email: organizationOnboarding.orgOwnerEmail,
role: "ADMIN" as const,
};
const onboardingService = new BillingEnabledOrgOnboardingService(userContext);
const { organization } = await onboardingService.createOrganization(organizationOnboarding, {
subscriptionId: paymentSubscriptionId,
subscriptionItemId: paymentSubscriptionItemId,
});
// Get the Stripe subscription object
@@ -5,7 +5,7 @@ import dayjs from "@calcom/dayjs";
import * as EmailManager from "@calcom/emails/email-manager";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { CreditType } from "@calcom/prisma/enums";
import { CreditService } from "./credit-service";
@@ -61,7 +61,7 @@ vi.mock("@calcom/prisma/enums", async (importOriginal) => {
vi.mock("@calcom/lib/server/repository/credits");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
vi.mock("@calcom/lib/server/repository/team");
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
vi.mock("@calcom/emails/email-manager");
vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
cancelScheduledMessagesAndScheduleEmails: vi.fn().mockResolvedValue(undefined),
@@ -13,7 +13,7 @@ import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma, { type PrismaTransaction } from "@calcom/prisma";
import type { CreditUsageType } from "@calcom/prisma/enums";
import { CreditType } from "@calcom/prisma/enums";
@@ -801,7 +801,6 @@ describe("addValueForMember", () => {
membershipId: membership.id,
// Engineering
attributeOptionId: "101",
//@ts-expect-error - prismock can't handle string ids it seems
dsyncId: dsync.id,
});
@@ -1,11 +1,15 @@
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { PrismaAttributeOptionRepository } from "../../../server/repository/PrismaAttributeOptionRepository";
import { PrismaAttributeRepository } from "../../../server/repository/PrismaAttributeRepository";
import type { AttributeId, AttributeName, BulkAttributeAssigner, AttributeOptionAssignment } from "../types";
import { PrismaAttributeOptionRepository } from "@calcom/lib/server/repository/PrismaAttributeOptionRepository";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { findAssignmentsForMember } from "@calcom/lib/service/attribute/server/utils";
import type {
AttributeId,
AttributeName,
BulkAttributeAssigner,
AttributeOptionAssignment,
} from "@calcom/lib/service/attribute/types";
import {
doesSupportMultipleValues,
isAssignmentForLockedAttribute,
@@ -13,8 +17,8 @@ import {
isAssignmentSame,
buildSlugFromValue,
canSetValueBeyondOptions,
} from "../utils";
import { findAssignmentsForMember } from "./utils";
} from "@calcom/lib/service/attribute/utils";
import prisma from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
@@ -1,11 +1,10 @@
import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson";
import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { assignValueToUserInOrgBulk } from "@calcom/lib/service/attribute/server/assignValueToUser";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
@@ -13,6 +12,7 @@ import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/team
import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { assignValueToUserInOrgBulk } from "./assignValueToUser";
import getAttributesFromScimPayload from "./getAttributesFromScimPayload";
import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg";
import dSyncUserSelect from "./users/dSyncUserSelect";
@@ -1,50 +1,79 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { showToast } from "@calcom/ui/components/toast";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
export const AdminOnboardingHandover = () => {
const { t } = useLocale();
const router = useRouter();
const session = useSession();
const isAdmin = session.data?.user.role === "ADMIN";
const { useOnboardingStore } = useOnboarding();
const { onboardingId, orgOwnerEmail } = useOnboardingStore();
if (!isAdmin) {
return null;
}
const onboardingUrl = `${WEBAPP_URL}/settings/organizations/new`;
// Admin handover page should ONLY use store data, not DB query
// The DB query returns the admin's own onboarding, not the one being handed over
if (!onboardingId) {
return <Alert severity="error" title={t("error")} message="No onboarding ID found. Please try again." />;
}
const onboardingUrl = `${WEBAPP_URL}/settings/organizations/new/resume?onboardingId=${onboardingId}`;
return (
<div className="bg-subtle rounded-md p-4">
<div className="flex-col items-center gap-2">
<span className="text-emphasis font-medium">
User can now continue onboarding with the following link
</span>
<div className="mt-2 flex items-center gap-2">
<code
data-testid="onboarding-url"
className="bg-default text-default flex w-full items-center truncate rounded-md px-3 py-2 font-mono text-sm">
{onboardingUrl}
</code>
<Button
data-testid="copy-onboarding-url"
type="button"
variant="icon"
color="secondary"
className="h-9 w-9"
onClick={() => {
navigator.clipboard.writeText(onboardingUrl);
showToast(t("link_copied"), "success");
}}
StartIcon="clipboard"
tooltip={t("copy_to_clipboard")}
/>
<div className="space-y-6">
{/* Instructions Card */}
<div className="bg-subtle rounded-lg p-6">
<h3 className="text-emphasis mb-2 text-lg font-medium">{t("next_steps")}</h3>
<p className="text-default mb-4 text-sm">
Send the following link to <strong>{orgOwnerEmail}</strong> so they can complete the organization
setup:
</p>
<div className="space-y-3">
<div className="bg-default flex items-center gap-2 rounded-md border p-3">
<code className="text-default flex-1 truncate font-mono text-sm" data-testid="onboarding-url">
{onboardingUrl}
</code>
<Button
data-testid="copy-onboarding-url"
type="button"
size="sm"
color="secondary"
onClick={() => {
navigator.clipboard.writeText(onboardingUrl);
showToast(t("link_copied"), "success");
}}
StartIcon="clipboard">
{t("copy")}
</Button>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<Button color="minimal" StartIcon="arrow-left" onClick={() => router.push("/settings/organizations")}>
{t("back")}
</Button>
<Button
color="primary"
onClick={() => {
router.push("/event-types");
}}>
{t("done")}
</Button>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More