Compare commits

...
Author SHA1 Message Date
emrysal 608fe1c586 chore: release v5.7.4
Post release / release (push) Canceled after 0s
2025-09-24 05:21:33 +00:00
Syed Ali Shahbaz 4413295b27 feat: Add spam block schema and migration (#23996)
* schema

* fix migration

* --

* optional org

* --

* remove prisma level relation

* --

* rename

* remove redundant key

* --
2025-09-24 03:50:25 +01:00
Anik Dhabal Babu bcc433ba9a fix: reschedule flow is broken for seated booking (#23987)
* fix: reschedule flow is broken for seated booking

* Update BookingListItem.tsx

* Update

* tweak
2025-09-23 22:05:08 +01:00
sean-brydon 2ba571d230 Attach org to impersonation return (#24012) 2025-09-23 18:34:16 +00:00
Hariom BalharaandAnik Dhabal Babu 4a0a1ef053 fix: Update documentation as per code (#23994)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-23 23:13:00 +05:30
Udit Takkar 7e882eedf8 fix: remove booking question after workflow deletion (#23901)
* fix: remove booking question after workflow deletion

* fix
2025-09-23 15:58:56 +00:00
sean-brydonandcoderabbitai[bot] 224e606439 feat: pbac org billing (#23709)
* refactor layout to not check session

* add actions for orgs + org admins

* update types on actions to be correctly non nullable

* Add tests for utils

* Apply suggestion from @coderabbitai[bot]

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

* restore lock file

* add permission check action for org authentication managment

* WIP BRANCH

* Git merge fix conflicts

* Fix imports

* intro to pbac team billing

* restore log

* refactor to be a billing portal factory service

* fix merge conflict

* Fix merge conflicts

* Passing test with non hardcoded vars

* fix migration

* Wip

* remove layout permision checks

* Fix type check

* remove logs

* improve error handling and logs

* Fix nits

* nits

* Use push instead of slice for billing tab

* Add permissions to memo

* Fix credits handlers to use PBAC also

* fix imports

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-23 14:01:21 +01:00
Morgan 14b3344a41 fix: optional auth guard only secret provided (#23977) 2025-09-23 08:22:45 +00:00
Hariom Balhara a538ba64d2 Add validation tests (#23833) 2025-09-23 04:45:47 +00:00
Adarsh Singh b3bfad7fea fix(ui): Correct phone number dropdown position in RTL layouts (#23964)
* fix(ui): correct phone input dropdown position in RTL

* chore: Remove explanatory comments per review
2025-09-23 01:22:06 +00:00
emrysal f80b45e36a chore: release v5.7.3
Post release / release (push) Canceled after 0s
2025-09-23 00:52:24 +00:00
f3d60b141f fix: resolve 404 error when rescheduling seated event bookings (#23956)
* fix: skip select field validation for existing responses during reschedule

- Modify hasRequiredBookingFieldsResponses to detect reschedule context
- Skip validation for select, multiselect, checkbox, and radio fields during reschedule to preserve existing responses
- Fixes validation error when rescheduling bookings with select field responses that may no longer match current field options

Resolves issue where reschedule operations fail with 'Value Travel, Adventure & Wildlife is not valid for type select for field Niche-project' error

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

* fix: skip frontend validation for existing select field responses during reschedule

- Revert backend changes in bookings.service.ts as the issue is frontend-only
- Add reschedule detection in getBookingResponsesSchema.ts preprocess function
- Skip option validation for select, radio, multiselect, and checkbox fields during reschedule
- Preserves existing booking responses that may no longer match current field options
- Fixes error 'Value Travel, Adventure & Wildlife is not valid for type select for field Niche-project' during slot selection

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

* fix: extend reschedule validation skip to handle multiemail fields like guests

- Add reschedule-aware logic to multiemail validation in getBookingResponsesSchema.ts
- Skip validation for existing multiemail responses during reschedule to avoid type mismatch errors
- Fixes 'Value is not valid for type text for field guests' error during slot selection
- Complements existing fix for select/radio field validation during reschedule

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

* fix: disable guests field inputs for seated events

- Disable type SelectField for guests field in seated events
- Disable identifier InputField for guests field in seated events
- Add validation to prevent saving guests field changes for seated events
- Pass seatsEnabled prop from EventAdvancedTab to FormBuilder
- Use condition: seatsEnabled && formFieldType === 'multiemail' && fieldForm.getValues('name') === 'guests'
- Fix linting warnings: remove unused variable and add missing dependency

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

* Update getBookingResponsesSchema.ts

* Update FormBuilder.tsx

* Update FormBuilder.tsx

* update

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-22 23:44:46 +00:00
5463dc261c fix: prevent external CSS from breaking Cal.com embed (#22778)
* fix: added block display to .cal-embed

* fixed the iframe color-scheme to unset and bg transparent

* removed forced CSS

* changes reverted from iframe

* Add comments and remove background transparent as it is still not needed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-09-22 19:10:06 +00:00
45 changed files with 1735 additions and 154 deletions
View File
View File
@@ -2,6 +2,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import {
NO_AUTH_PROVIDED_MESSAGE,
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
export class OptionalApiAuthGuard extends ApiAuthGuard {
@@ -9,11 +10,16 @@ export class OptionalApiAuthGuard extends ApiAuthGuard {
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
const onlyClientSecretProvided = error && error.message.includes(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
if (onlyClientIdProvided) {
return null;
}
if (onlyClientSecretProvided) {
return null;
}
if (user || noAuthProvided || !error) {
return user || null;
} else {
@@ -32,7 +32,11 @@ import { randomString } from "test/utils/randomString";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
import { ApiAuthGuardRequest, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
import {
ApiAuthGuardRequest,
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
} from "./api-auth.strategy";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -267,6 +271,29 @@ describe("ApiAuthStrategy", () => {
}
});
it("should throw 401 if only OAuth Client Secret is provided", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_SECRET_KEY]: `${oAuthClient.secret}gibberish`,
},
get: (key: string) => ({ origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
expect(error.message).toContain(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
}
}
});
it("should throw 401 if OAuth ID is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
@@ -29,11 +29,11 @@ export type ApiAuthGuardRequest = Request & {
user: ApiAuthGuardUser;
allowedAuthMethods?: AllowedAuthMethod[];
};
export const NO_AUTH_PROVIDED_MESSAGE =
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
export const NO_AUTH_PROVIDED_MESSAGE = `No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as '${X_CAL_SECRET_KEY}' and '${X_CAL_CLIENT_ID}' headers`;
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE =
"Only 'x-cal-client-id' header provided. Please also provide 'x-cal-secret-key' header or Auth bearer token as 'Authentication' header";
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE = `Only '${X_CAL_CLIENT_ID}' header provided. Please also provide '${X_CAL_SECRET_KEY}' header or Auth bearer token as 'Authentication' header`;
export const ONLY_CLIENT_SECRET_PROVIDED_MESSAGE = `Only '${X_CAL_SECRET_KEY}' header provided. Please also provide '${X_CAL_CLIENT_ID}' header or Auth bearer token as 'Authentication' header`;
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
@@ -121,6 +121,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
const onlyClientSecretProvided =
!oAuthClientId && !!oAuthClientSecret && !bearerToken && !nextAuthToken;
if (noAuthProvided) {
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
}
@@ -129,6 +132,10 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
}
if (onlyClientSecretProvided) {
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_SECRET_PROVIDED_MESSAGE}`);
}
throw new UnauthorizedException(
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
+6 -6
View File
@@ -16780,7 +16780,7 @@
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"locations": {
"type": "array",
@@ -18449,7 +18449,7 @@
},
"bookingRequiresAuthentication": {
"type": "boolean",
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"ownerId": {
"type": "number",
@@ -18808,7 +18808,7 @@
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"locations": {
"type": "array",
@@ -20644,7 +20644,7 @@
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"schedulingType": {
"type": "string",
@@ -21085,7 +21085,7 @@
},
"bookingRequiresAuthentication": {
"type": "boolean",
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"teamId": {
"type": "number"
@@ -21539,7 +21539,7 @@
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"hosts": {
"type": "array",
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
name: "privacy",
href: "/settings/organizations/privacy",
},
{
name: "billing",
href: "/settings/organizations/billing",
},
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
{
name: "SSO",
@@ -173,17 +170,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = [
"privacy",
"billing",
"OAuth Clients",
"SSO",
"directory_sync",
"delegation_credential",
];
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
}
const useTabs = ({
@@ -232,11 +223,27 @@ const useTabs = ({
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
if (isPbacEnabled && permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
});
if (isPbacEnabled) {
if (permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
});
}
if (permissions?.canViewOrganizationBilling) {
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
});
}
} else {
if (isOrgAdminOrOwner) {
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
});
}
}
return {
@@ -272,7 +279,7 @@ const useTabs = ({
if (isAdmin) return true;
return !adminRequiredKeys.includes(tab.name);
});
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
return processTabsMemod;
};
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
@@ -45,13 +46,16 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let teamFeatures: Record<number, TeamFeatures> | null = null;
let canViewRoles = false;
let canViewOrganizationBilling = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const [features, rolePermissions] = await Promise.all([
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
const [features, rolePermissions, organizationPermissions] = await Promise.all([
getTeamFeatures(orgId),
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
]);
if (features) {
@@ -62,6 +66,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
// Check if user has permission to read roles
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
canViewRoles = roleActions[CrudAction.Read] ?? false;
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
}
}
@@ -70,7 +76,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles }}
permissions={{ canViewRoles, canViewOrganizationBilling }}
/>
</>
);
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import BillingView from "~/settings/billing/billing-view";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -18,9 +19,11 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrgAdmin();
// TODO(SEAN): Add PBAC to this page in the next PR
await validateUserHasOrgPerms({
permission: "organization.manageBilling",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
return (
<SettingsHeader
@@ -1,24 +1,4 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userProfile = session?.user?.profile;
const userId = session?.user?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
if (!isOrgAdminOrOwner) {
return redirect("/settings/organizations/profile");
}
return children;
};
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import type { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrg } from "./validateUserHasOrg";
export const validateUserHasOrgPerms = async ({
redirectTo,
fallbackRoles,
permission,
}: {
redirectTo?: string;
permission: PermissionString;
fallbackRoles: MembershipRole[];
}) => {
const session = await validateUserHasOrg();
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: session.user.id,
teamId: session.user.org.id,
permission,
fallbackRoles,
});
if (!hasPermission) {
redirect(redirectTo || "/settings/my-account/profile");
}
return session;
};
@@ -185,6 +185,10 @@ function BookingListItem(booking: BookingItemProps) {
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
const paymentAppData = getPaymentAppData(booking.eventType);
const location = booking.location as ReturnType<typeof getEventLocationValue>;
@@ -223,10 +227,7 @@ function BookingListItem(booking: BookingItemProps) {
};
const getSeatReferenceUid = () => {
if (!booking.seatsReferences[0]) {
return undefined;
}
return booking.seatsReferences[0].referenceUid;
return userSeat?.referenceUid;
};
const actionContext: BookingActionContext = {
@@ -250,6 +251,7 @@ function BookingListItem(booking: BookingItemProps) {
booking.location === "integrations:daily" ||
(typeof booking.location === "string" && booking.location.trim() === ""),
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
isAttendee,
cardCharged,
attendeeList,
getSeatReferenceUid,
@@ -21,6 +21,7 @@ export interface BookingActionContext {
isDisabledRescheduling: boolean;
isCalVideoLocation: boolean;
showPendingPayment: boolean;
isAttendee: boolean;
cardCharged: boolean;
attendeeList: Array<{
name: string;
@@ -102,6 +103,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
isDisabledRescheduling,
isBookingFromRoutingForm,
getSeatReferenceUid,
isAttendee,
t,
} = context;
@@ -111,7 +113,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "clock",
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
disabled:
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
@@ -5,8 +5,8 @@ import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
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/lib/server/repository/user";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
uid,
seatReferenceUid: maybeSeatReferenceUid,
bookingSeat,
} = await maybeGetBookingUidFromSeat(prisma, bookingUid);
} = await maybeGetBookingUidFromSeat(prisma, seatReferenceUid ? seatReferenceUid : bookingUid);
const booking = await prisma.booking.findUnique({
where: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.7.2",
"version": "5.7.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+123
View File
@@ -458,5 +458,128 @@ test.describe("Reschedule for booking with seats", () => {
await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1);
});
test("Host reschedule from /upcoming page should have rescheduleUid parameter set to bookingUid", async ({
page,
users,
bookings,
}) => {
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await user.apiLogin();
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
await page.goto("/bookings/upcoming");
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const rescheduleUid = url.searchParams.get("rescheduleUid");
return !!rescheduleUid && rescheduleUid === booking.uid;
});
});
test("Second attendee reschedule from /upcoming page should use correct seatReferenceUid and show attendee info", async ({
page,
users,
bookings,
}) => {
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
orderBy: { id: "asc" },
});
const secondUser = await users.create({
name: "Jane Second",
email: "second+seats@cal.com",
});
await secondUser.apiLogin();
await page.goto("/bookings/upcoming");
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const rescheduleUid = url.searchParams.get("rescheduleUid");
return !!rescheduleUid && rescheduleUid === references[1].referenceUid;
});
await selectFirstAvailableTimeSlotNextMonth(page);
const nameElement = page.locator("input[name=name]");
const name = await nameElement.inputValue();
expect(name).toBe("Jane Second");
const emailElement = page.locator("input[name=email]");
const email = await emailElement.inputValue();
expect(email).toBe("second+seats@cal.com");
// Complete the reschedule
await confirmReschedule(page);
// Verify successful reschedule
await page.waitForURL(/\/booking\/.*/);
await expect(page).toHaveURL(/\/booking\/.*/);
});
// @TODO: force 404 when rescheduleUid is not found
});
@@ -2202,6 +2202,7 @@
"booking_confirmation_failed": "Booking confirmation failed",
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"guests_field_must_be_multiemail": "Guests field must be of type 'Multiple emails'",
"show_on_booking_page": "Show on booking page",
"visit_cancelled_booking": "You can visit the canceled booking page",
"get_started_zapier_templates": "Get started with Zapier templates",
+5
View File
@@ -647,3 +647,8 @@ select:focus {
[data-radix-popper-content-wrapper] {
border: none;
}
html[dir="rtl"] .react-tel-input .flag-dropdown {
left: auto !important;
right: 0 !important;
}
@@ -1534,6 +1534,7 @@ export function getOrganizer({
completedOnboarding,
username,
locked,
emailVerified,
}: {
name: string;
email: string;
@@ -1551,6 +1552,7 @@ export function getOrganizer({
completedOnboarding?: boolean;
username?: string;
locked?: boolean;
emailVerified?: Date | null;
}) {
username = username ?? TestData.users.example.username;
return {
@@ -1572,7 +1574,8 @@ export function getOrganizer({
smsLockState,
completedOnboarding,
locked,
};
emailVerified,
};
}
export function getScenarioData(
@@ -0,0 +1,241 @@
import type { NextApiRequest } from "next";
import type { Session } from "next-auth";
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 { MembershipRole } from "@calcom/prisma/enums";
import {
BillingPortalServiceFactory,
TeamBillingPortalService,
OrganizationBillingPortalService,
UserBillingPortalService,
} from "../../lib/BillingPortalService";
import * as customerModule from "../../lib/customer";
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("../../lib/customer");
vi.mock("../../lib/server");
vi.mock("../../lib/subscriptions");
vi.mock("@calcom/prisma", () => ({
default: {},
}));
const mockPermissionService = vi.mocked(PermissionCheckService);
const mockTeamRepository = vi.mocked(TeamRepository);
const mockCustomerModule = vi.mocked(customerModule);
interface RequestWithSession extends NextApiRequest {
session?: Session | null;
}
interface MockPermissionService {
checkPermission: ReturnType<typeof vi.fn>;
}
interface MockTeamRepository {
findById: ReturnType<typeof vi.fn>;
}
describe("Portal API - Service-Based Architecture", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("validateAuthentication", () => {
it("should return user when session exists", () => {
const req = {
session: {
user: { id: 123 },
hasValidLicense: true,
upId: "test-upid",
expires: "2024-12-31T23:59:59.999Z"
} as Session,
} as RequestWithSession;
const result = validateAuthentication(req as NextApiRequest);
expect(result).toEqual({ id: 123 });
});
it("should return null when session is missing", () => {
const req = {} as NextApiRequest;
const result = validateAuthentication(req);
expect(result).toBeNull();
});
it("should return null when user id is missing", () => {
const req = {
session: {
user: {} as any,
hasValidLicense: true,
upId: "test-upid",
expires: "2024-12-31T23:59:59.999Z"
} as Session,
} as RequestWithSession;
const result = validateAuthentication(req as NextApiRequest);
expect(result).toBeNull();
});
});
describe("buildReturnUrl", () => {
it("should return default URL when returnTo is not provided", () => {
const result = buildReturnUrl();
expect(result).toBe(`${WEBAPP_URL}/settings/billing`);
});
it("should return default URL when returnTo is not a string", () => {
const result = buildReturnUrl(123 as unknown as string);
expect(result).toBe(`${WEBAPP_URL}/settings/billing`);
});
it("should return safe redirect URL when valid returnTo is provided", () => {
const returnTo = `${WEBAPP_URL}/settings/teams`;
const result = buildReturnUrl(returnTo);
expect(result).toBe(`${WEBAPP_URL}/settings/teams`);
});
it("should return WEBAPP_URL root when unsafe redirect URL is provided", () => {
const returnTo = "http://malicious-site.com";
const result = buildReturnUrl(returnTo);
expect(result).toBe(`${WEBAPP_URL}/`);
});
});
describe("BillingPortalServiceFactory", () => {
let mockTeamRepo: MockTeamRepository;
beforeEach(() => {
mockTeamRepo = {
findById: vi.fn(),
};
mockTeamRepository.mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
});
it("should create OrganizationBillingPortalService for organizations", async () => {
const mockTeam = { id: 1, isOrganization: true, metadata: {} };
mockTeamRepo.findById.mockResolvedValue(mockTeam);
const service = await BillingPortalServiceFactory.createService(1);
expect(service).toBeInstanceOf(OrganizationBillingPortalService);
});
it("should create TeamBillingPortalService for regular teams", async () => {
const mockTeam = { id: 1, isOrganization: false, metadata: {} };
mockTeamRepo.findById.mockResolvedValue(mockTeam);
const service = await BillingPortalServiceFactory.createService(1);
expect(service).toBeInstanceOf(TeamBillingPortalService);
});
it("should throw error when team not found", async () => {
mockTeamRepo.findById.mockResolvedValue(null);
await expect(BillingPortalServiceFactory.createService(1)).rejects.toThrow("Team not found");
});
it("should create UserBillingPortalService", () => {
const service = BillingPortalServiceFactory.createUserService();
expect(service).toBeInstanceOf(UserBillingPortalService);
});
});
describe("TeamBillingPortalService", () => {
let service: TeamBillingPortalService;
let mockPermissionServiceInstance: MockPermissionService;
beforeEach(() => {
mockPermissionServiceInstance = {
checkPermission: vi.fn().mockResolvedValue(true),
};
vi.mocked(PermissionCheckService).mockImplementation(
() => mockPermissionServiceInstance as unknown as PermissionCheckService
);
const mockTeamRepo: MockTeamRepository = {
findById: vi.fn(),
};
mockTeamRepository.mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
service = new TeamBillingPortalService();
});
it("should check team.manageBilling permission", async () => {
const result = await service.checkPermissions(123, 456);
expect(mockPermissionServiceInstance.checkPermission).toHaveBeenCalledWith({
userId: 123,
teamId: 456,
permission: "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
expect(result).toBe(true);
});
});
describe("OrganizationBillingPortalService", () => {
let service: OrganizationBillingPortalService;
let mockPermissionServiceInstance: MockPermissionService;
beforeEach(() => {
mockPermissionServiceInstance = {
checkPermission: vi.fn().mockResolvedValue(true),
};
vi.mocked(PermissionCheckService).mockImplementation(
() => mockPermissionServiceInstance as unknown as PermissionCheckService
);
service = new OrganizationBillingPortalService();
});
it("should check organization.manageBilling permission", async () => {
const result = await service.checkPermissions(123, 456);
expect(mockPermissionServiceInstance.checkPermission).toHaveBeenCalledWith({
userId: 123,
teamId: 456,
permission: "organization.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
expect(result).toBe(true);
});
});
describe("UserBillingPortalService", () => {
let service: UserBillingPortalService;
beforeEach(() => {
service = new UserBillingPortalService();
mockCustomerModule.getStripeCustomerIdFromUserId = vi.fn();
});
it("should get customer ID for user", async () => {
mockCustomerModule.getStripeCustomerIdFromUserId.mockResolvedValue("cus_123");
const result = await service.getCustomerId(123);
expect(result).toBe("cus_123");
expect(mockCustomerModule.getStripeCustomerIdFromUserId).toHaveBeenCalledWith(123);
});
});
});
+41 -69
View File
@@ -1,87 +1,59 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
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 prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getStripeCustomerIdFromUserId } from "../lib/customer";
import stripe from "../lib/server";
import { getSubscriptionFromId } from "../lib/subscriptions";
import { BillingPortalServiceFactory } from "../lib/BillingPortalService";
const getBillingPortalUrl = async (customerId: string, return_url: string) => {
const log = logger.getSubLogger({ prefix: ["getBillingPortalUrl"] });
try {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
interface AuthenticatedUser {
id: number;
}
return portalSession.url;
} catch (e) {
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
throw new Error("Failed to create billing portal session");
}
interface RequestWithSession extends NextApiRequest {
session?: Session | null;
}
export const validateAuthentication = (req: NextApiRequest): AuthenticatedUser | null => {
const userId = (req as RequestWithSession).session?.user?.id;
if (!userId) return null;
return { id: userId };
};
export const buildReturnUrl = (returnTo?: string): string => {
const defaultUrl = `${WEBAPP_URL}/settings/billing`;
if (typeof returnTo !== "string") return defaultUrl;
const safeRedirectUrl = getSafeRedirectUrl(returnTo);
return safeRedirectUrl || defaultUrl;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST" && req.method !== "GET")
if (req.method !== "POST" && req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
const user = validateAuthentication(req);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const userId = req.session.user.id;
const teamId = req.query.teamId ? parseInt(req.query.teamId as string) : null;
let return_url = `${WEBAPP_URL}/settings/billing`;
if (!teamId) {
const customerId = await getStripeCustomerIdFromUserId(userId);
if (!customerId) return res.status(404).json({ message: "CustomerId not found" });
const returnUrl = buildReturnUrl(req.query.returnTo as string);
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
try {
if (!teamId) {
const userService = BillingPortalServiceFactory.createUserService();
return await userService.processBillingPortal(user.id, returnUrl, res);
}
return res.redirect(302, billingPortalUrl);
const billingService = await BillingPortalServiceFactory.createService(teamId);
return await billingService.processBillingPortal(user.id, teamId, returnUrl, res);
} catch (error) {
if (error instanceof Error && error.message === "Team not found") {
return res.status(404).json({ message: "Team not found" });
}
throw error;
}
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.getTeamByIdIfUserIsAdmin({
teamId,
userId,
});
if (!team) return res.status(404).json({ message: "Team not found" });
if (typeof req.query.returnTo === "string") {
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
if (safeRedirectUrl) return_url = safeRedirectUrl;
}
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
if (!teamMetadataParsed.success) {
return res.status(400).json({ message: "Invalid team metadata" });
}
if (!teamMetadataParsed.data?.subscriptionId) {
return res.status(400).json({ message: "subscriptionId not found for team" });
}
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
if (!subscription) {
return res.status(400).json({ message: "Subscription not found" });
}
if (!subscription.customer) {
return res.status(400).json({ message: "Subscription customer not found" });
}
const customerId = subscription.customer as string;
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
res.redirect(302, billingPortalUrl);
}
@@ -0,0 +1,10 @@
// Re-export all services for backward compatibility
export {
BillingPortalService,
TeamBillingPortalService,
OrganizationBillingPortalService,
UserBillingPortalService,
BillingPortalServiceFactory,
} from "./services";
export type { TeamEntity, BillingPortalResult } from "./services";
@@ -0,0 +1,104 @@
import type { NextApiResponse } from "next";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
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 prisma from "@calcom/prisma";
import stripe from "../../server";
export interface TeamEntity {
id: number;
metadata: unknown;
isOrganization: boolean;
}
export interface BillingPortalResult {
success: boolean;
customerId?: string;
portalUrl?: string;
}
export abstract class BillingPortalService {
protected permissionService: PermissionCheckService;
protected teamRepository: TeamRepository;
protected contextName = "Team"; // Can be overridden by subclasses
constructor() {
this.permissionService = new PermissionCheckService();
this.teamRepository = new TeamRepository(prisma);
}
/**
* Creates a billing portal URL for a Stripe customer
*/
protected async createBillingPortalUrl(customerId: string, returnUrl: string): Promise<string> {
const log = logger.getSubLogger({ prefix: ["createBillingPortalUrl"] });
try {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return portalSession.url;
} catch (e) {
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
throw new Error("Failed to create billing portal session");
}
}
/**
* Builds a safe return URL for the billing portal
*/
protected buildReturnUrl(returnTo?: string): string {
const defaultUrl = `${WEBAPP_URL}/settings/billing`;
if (typeof returnTo !== "string") return defaultUrl;
const safeRedirectUrl = getSafeRedirectUrl(returnTo);
return safeRedirectUrl || defaultUrl;
}
/**
* Abstract method to check permissions - implemented by subclasses
*/
abstract checkPermissions(userId: number, teamId: number): Promise<boolean>;
/**
* Abstract method to get customer ID - implemented by subclasses
*/
abstract getCustomerId(teamId: number): Promise<string | null>;
/**
* Process billing portal request for a team
*/
async processBillingPortal(
userId: number,
teamId: number,
returnUrl: string,
res: NextApiResponse
): Promise<void> {
// Check permissions
const hasPermission = await this.checkPermissions(userId, teamId);
if (!hasPermission) {
res.status(403).json({ message: "Forbidden" });
return;
}
// Get customer ID
const customerId = await this.getCustomerId(teamId);
if (!customerId) {
res.status(400).json({
message: `${this.contextName} billing not properly configured. Please contact support.`,
});
return;
}
// Create portal URL and redirect
const billingPortalUrl = await this.createBillingPortalUrl(customerId, returnUrl);
res.redirect(302, billingPortalUrl);
}
}
@@ -0,0 +1,37 @@
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import type { BillingPortalService } from "../base/BillingPortalService";
import { OrganizationBillingPortalService } from "../organization/OrganizationBillingPortalService";
import { TeamBillingPortalService } from "../team/TeamBillingPortalService";
import { UserBillingPortalService } from "../user/UserBillingPortalService";
/**
* Factory to create the appropriate billing portal service based on team type
*/
export class BillingPortalServiceFactory {
/**
* Determines team type and returns the appropriate service
*/
static async createService(teamId: number): Promise<BillingPortalService> {
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.findById({ id: teamId });
if (!team) {
throw new Error("Team not found");
}
if (team.isOrganization) {
return new OrganizationBillingPortalService();
}
return new TeamBillingPortalService();
}
/**
* Creates a user billing portal service
*/
static createUserService(): UserBillingPortalService {
return new UserBillingPortalService();
}
}
@@ -0,0 +1,6 @@
export { BillingPortalService } from "./base/BillingPortalService";
export type { TeamEntity, BillingPortalResult } from "./base/BillingPortalService";
export { TeamBillingPortalService } from "./team/TeamBillingPortalService";
export { OrganizationBillingPortalService } from "./organization/OrganizationBillingPortalService";
export { UserBillingPortalService } from "./user/UserBillingPortalService";
export { BillingPortalServiceFactory } from "./factory/BillingPortalServiceFactory";
@@ -0,0 +1,55 @@
import logger from "@calcom/lib/logger";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getSubscriptionFromId } from "../../subscriptions";
import { BillingPortalService } from "../base/BillingPortalService";
/**
* Billing portal service for organizations
*/
export class OrganizationBillingPortalService extends BillingPortalService {
constructor() {
super();
this.contextName = "Organization";
}
async checkPermissions(userId: number, teamId: number): Promise<boolean> {
return await this.permissionService.checkPermission({
userId,
teamId,
permission: "organization.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
}
async getCustomerId(teamId: number): Promise<string | null> {
const log = logger.getSubLogger({ prefix: ["OrganizationBillingPortalService", "getCustomerId"] });
const team = await this.teamRepository.findById({ id: teamId });
if (!team) return null;
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
return null;
}
try {
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
if (!subscription?.customer) {
log.warn("Subscription found but no customer ID", {
teamId,
subscriptionId: teamMetadataParsed.data.subscriptionId,
});
return null;
}
return subscription.customer as string;
} catch (error) {
log.error("Failed to retrieve subscription", { teamId, error });
return null;
}
}
}
@@ -0,0 +1,50 @@
import logger from "@calcom/lib/logger";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getSubscriptionFromId } from "../../subscriptions";
import { BillingPortalService } from "../base/BillingPortalService";
/**
* Billing portal service for regular teams
*/
export class TeamBillingPortalService extends BillingPortalService {
async checkPermissions(userId: number, teamId: number): Promise<boolean> {
return await this.permissionService.checkPermission({
userId,
teamId,
permission: "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
}
async getCustomerId(teamId: number): Promise<string | null> {
const log = logger.getSubLogger({ prefix: ["TeamBillingPortalService", "getCustomerId"] });
const team = await this.teamRepository.findById({ id: teamId });
if (!team) return null;
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
return null;
}
try {
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
if (!subscription?.customer) {
log.warn("Subscription found but no customer ID", {
teamId,
subscriptionId: teamMetadataParsed.data.subscriptionId,
});
return null;
}
return subscription.customer as string;
} catch (error) {
log.error("Failed to retrieve subscription", { teamId, error });
return null;
}
}
}
@@ -0,0 +1,51 @@
import type { NextApiResponse } from "next";
import logger from "@calcom/lib/logger";
import { getStripeCustomerIdFromUserId } from "../../customer";
import stripe from "../../server";
/**
* Billing portal service for individual users
*/
export class UserBillingPortalService {
/**
* Get customer ID for a user
*/
async getCustomerId(userId: number): Promise<string | null> {
return await getStripeCustomerIdFromUserId(userId);
}
/**
* Process billing portal request for a user
*/
async processBillingPortal(userId: number, returnUrl: string, res: NextApiResponse): Promise<void> {
const customerId = await this.getCustomerId(userId);
if (!customerId) {
res.status(404).json({ message: "CustomerId not found" });
return;
}
const billingPortalUrl = await this.createBillingPortalUrl(customerId, returnUrl);
res.redirect(302, billingPortalUrl);
}
/**
* Creates a billing portal URL for a Stripe customer
*/
private async createBillingPortalUrl(customerId: string, returnUrl: string): Promise<string> {
const log = logger.getSubLogger({ prefix: ["createBillingPortalUrl"] });
try {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return portalSession.url;
} catch (e) {
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
throw new Error("Failed to create billing portal session");
}
}
}
+5
View File
@@ -6,4 +6,9 @@
min-height: 300px;
margin: 0 auto;
width: 100%;
/**
* Following properties are added to ensure that the embedding page isn't able to affect these properties
*/
display: block !important; /* A website made all iframes' display flex breaking the embed */
color-scheme: unset !important; /* A website had explicitly set color-scheme to something for all iframes */
}
@@ -0,0 +1,365 @@
/**
* Booking Validation Specifications
* These specifications verify the business rules and validation behavior for booking creation
*/
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import {
createBookingScenario,
TestData,
getOrganizer,
getBooker,
getScenarioData,
getGoogleCalendarCredential,
mockCalendarToHaveNoBusySlots,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { afterEach, vi } from "vitest";
import { describe, expect } from "vitest";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { getNewBookingHandler } from "./getNewBookingHandler";
function addToBlacklistedEmails(emails: string[]) {
process.env.BLACKLISTED_GUEST_EMAILS = emails.join(",");
}
function resetBlacklistedEmails() {
delete process.env.BLACKLISTED_GUEST_EMAILS;
}
afterEach(() => {
resetBlacklistedEmails();
});
describe("Booking Validation Specifications", () => {
setupAndTeardown();
describe("Email Blacklist Validation", () => {
test("when email is in BLACKLISTED_GUEST_EMAILS, allow the user to book only if they are logged in with that email", async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "organizer@example.com"; // Use organizer's email as the blocked one
const booker = getBooker({
email: blockedEmail,
name: "Organizer",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
emailVerified: new Date(),
});
addToBlacklistedEmails(["organizer@example.com", "spam@test.com"]);
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
// Non logged in user should not be able to book
await expect(handleNewBooking({
bookingData: mockBookingData,
})).rejects.toThrow(
"Attendee email has been blocked. Make sure to login as organizer@example.com to use this email for creating a booking."
);
// Should allow booking when the user who owns the blacklisted email is logged in
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
userId: 101, // Same as organizer who owns the blacklisted email
});
expect(createdBooking).toEqual(
expect.objectContaining({
id: expect.any(Number),
uid: expect.any(String),
status: BookingStatus.ACCEPTED,
})
);
});
test("prevents booking when blacklisted email is not verified in the system", async () => {
const handleNewBooking = getNewBookingHandler();
const blockedEmail = "blocked@example.com";
const booker = getBooker({
email: blockedEmail,
name: "Unverified User",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
emailVerified: null,
});
// Mock environment variable for blacklisted emails
addToBlacklistedEmails(["blocked@example.com"]);
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
// Should prevent booking when blacklisted email has no verified user in database
await expect(handleNewBooking({
bookingData: mockBookingData,
})).rejects.toThrow("Cannot use this email to create the booking.");
});
});
describe("Active Bookings Limit Validation", () => {
test("allows booking when user is under their active booking limit", async () => {
vi.setSystemTime(new Date("2025-01-01"));
const plus1DateString = "2025-01-02";
const handleNewBooking = getNewBookingHandler();
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
// Create test scenario with event type that has maxActiveBookingsPerBooker limit
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
// Two bookings allowed for the booker
maxActiveBookingsPerBooker: 2,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
bookings: [
{
uid: "existing-booking-1",
eventTypeId: 1,
userId: organizer.id,
startTime: `${plus1DateString}T10:00:00.000Z`,
endTime: `${plus1DateString}T10:30:00.000Z`,
title: "Existing Booking",
status: BookingStatus.ACCEPTED,
// Booker already has a booking in future
attendees: [{
email: booker.email,
}],
},
],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
// Should allow booking when user has not reached their limit (1 booking < 2 limit)
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(createdBooking).toEqual(
expect.objectContaining({
id: expect.any(Number),
uid: expect.any(String),
status: BookingStatus.ACCEPTED,
})
);
// Second booking should be rejected
await expect(handleNewBooking({
bookingData: mockBookingData,
})).rejects.toThrow("booker_limit_exceeded_error");
});
test("enforces booking limits with reschedule option when enabled", async () => {
vi.setSystemTime(new Date("2025-01-01"));
const plus1DateString = "2025-01-02";
const plus2DateString = "2025-01-03";
const handleNewBooking = getNewBookingHandler();
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
// Create test scenario with event type that has reschedule option enabled
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
maxActiveBookingsPerBooker: 2,
maxActiveBookingPerBookerOfferReschedule: true,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
bookings: [
{
uid: "existing-booking-1",
eventTypeId: 1,
userId: organizer.id,
startTime: `${plus1DateString}T10:00:00.000Z`,
endTime: `${plus1DateString}T10:30:00.000Z`,
title: "Existing Booking",
status: BookingStatus.ACCEPTED,
attendees: [{
email: booker.email,
}],
},
{
uid: "existing-booking-2",
eventTypeId: 1,
userId: organizer.id,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T10:30:00.000Z`,
title: "Existing Booking",
status: BookingStatus.ACCEPTED,
attendees: [{
email: booker.email,
}],
},
],
})
);
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
try {
await handleNewBooking({
bookingData: mockBookingData,
});
} catch (error) {
expect(error.message).toEqual("booker_limit_exceeded_error_reschedule");
expect(error.data).toEqual(
expect.objectContaining({
rescheduleUid: "existing-booking-1",
})
);
}
});
});
});
@@ -3,6 +3,7 @@ import CredentialsProvider from "next-auth/providers/credentials";
import { z } from "zod";
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
import { getSession } from "@calcom/features/auth/lib/getSession";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
@@ -53,6 +54,8 @@ const auditAndReturnNextUser = async (
},
});
const profileOrg = impersonatedUser.profile?.organization;
const obj = {
id: impersonatedUser.id,
username: impersonatedUser.username,
@@ -63,6 +66,18 @@ const auditAndReturnNextUser = async (
organizationId: impersonatedUser.organizationId,
locale: impersonatedUser.locale,
profile: impersonatedUser.profile,
// Add org object if the user belongs to an organization
...(profileOrg && {
org: {
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug || "",
logoUrl: profileOrg.logoUrl,
fullDomain: getOrgFullOrigin(profileOrg.slug || ""),
domainSuffix: subdomainSuffix(),
role: profileOrg.members?.[0]?.role || MembershipRole.MEMBER,
},
}),
};
if (!isReturningToSelf) {
@@ -1,10 +1,12 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { render } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { vi } from "vitest";
import { showToast } from "@calcom/ui/components/toast";
import { FormBuilder } from "./FormBuilder";
import {
mockProps,
@@ -32,6 +34,10 @@ vi.mock("next/navigation", async (importOriginal) => {
};
});
vi.mock("@calcom/ui/components/toast", () => ({
showToast: vi.fn(),
}));
const renderComponent = ({
formBuilderProps: formBuilderProps,
formDefaultValues: formDefaultValues,
@@ -219,7 +225,6 @@ describe("FormBuilder", () => {
],
});
verifier.verifyOptionPrices({ identifier, prices: [20, 25] });
});
@@ -271,4 +276,40 @@ describe("FormBuilder", () => {
verifier.verifyOptionPrices({ identifier, prices: [50, 75, 100] });
});
});
describe("Guests Field Validation Tests", () => {
beforeEach(() => {
renderComponent({ formBuilderProps: mockProps, formDefaultValues: {} });
});
it("Should prevent saving guests field with non-multiemail type", async () => {
const dialog = pageObject.openAddFieldDialog();
pageObject.dialog.selectFieldType({ dialog, fieldType: "text" });
pageObject.dialog.fillInFieldIdentifier({ dialog, identifier: "guests" });
pageObject.dialog.fillInFieldLabel({ dialog, label: "Guests", fieldType: "text" });
pageObject.dialog.saveField({ dialog });
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("guests_field_must_be_multiemail", "error");
});
expect(screen.queryByTestId("field-guests")).not.toBeInTheDocument();
});
it("Should allow saving guests field with multiemail type", async () => {
const dialog = pageObject.openAddFieldDialog();
pageObject.dialog.selectFieldType({ dialog, fieldType: "multiemail" });
pageObject.dialog.fillInFieldIdentifier({ dialog, identifier: "guests" });
pageObject.dialog.fillInFieldLabel({ dialog, label: "Guests", fieldType: "multiemail" });
pageObject.dialog.saveField({ dialog });
await waitFor(() => {
expect(screen.getByTestId("field-guests")).toBeInTheDocument();
});
});
});
});
@@ -380,6 +380,12 @@ export const FormBuilder = function FormBuilder({
handleSubmit={(data: Parameters<SubmitHandler<RhfFormField>>[0]) => {
const type = data.type || "text";
const isNewField = !fieldDialog.data;
if (data.name === "guests" && type !== "multiemail") {
showToast(t("guests_field_must_be_multiemail"), "error");
return;
}
if (isNewField && fields.some((f) => f.name === data.name)) {
showToast(t("form_builder_field_already_exists"), "error");
return;
@@ -294,6 +294,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_impersonate_team_members",
dependsOn: ["team.read", "team.listMembers"],
},
[CustomAction.ManageBilling]: {
description: "Manage billing",
category: "team",
i18nKey: "pbac_action_manage_billing",
descriptionI18nKey: "pbac_desc_manage_billing",
scope: [], // Empty scope because this permission is only used for TEAM billing outside of an org. (We dont want to show this in the UI)
},
},
[Resource.Organization]: {
_resource: {
+1 -1
View File
@@ -218,7 +218,7 @@ export class TeamService {
}
// TODO: Needs to be moved to repository
private static async fetchTeamOrThrow(teamId: number): Promise<TeamWithSettings> {
static async fetchTeamOrThrow(teamId: number): Promise<TeamWithSettings> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
@@ -0,0 +1,7 @@
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
('team', 'manageBilling')
) AS permissions(resource, action);
@@ -0,0 +1,52 @@
/*
Warnings:
- A unique constraint covering the columns `[type,value,organizationId]` on the table `Watchlist` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateEnum
CREATE TYPE "WatchlistAction" AS ENUM ('REPORT', 'BLOCK');
-- DropIndex
DROP INDEX "Watchlist_type_value_idx";
-- DropIndex
DROP INDEX "Watchlist_type_value_key";
-- AlterTable
ALTER TABLE "Watchlist" ADD COLUMN "action" "WatchlistAction" NOT NULL DEFAULT 'REPORT',
ADD COLUMN "organizationId" INTEGER;
-- CreateTable
CREATE TABLE "BlockedBookingLog" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"eventTypeId" INTEGER,
"organizationId" INTEGER,
"bookingData" JSONB,
"watchlistId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BlockedBookingLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BlockedBookingLog_organizationId_createdAt_idx" ON "BlockedBookingLog"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX "BlockedBookingLog_email_idx" ON "BlockedBookingLog"("email");
-- CreateIndex
CREATE INDEX "BlockedBookingLog_watchlistId_idx" ON "BlockedBookingLog"("watchlistId");
-- CreateIndex
CREATE INDEX "Watchlist_type_value_organizationId_action_idx" ON "Watchlist"("type", "value", "organizationId", "action");
-- CreateIndex
CREATE UNIQUE INDEX "Watchlist_type_value_organizationId_key" ON "Watchlist"("type","value","organizationId");
-- CreateIndex
-- Enforce uniqueness for global entries (organizationId IS NULL)
CREATE UNIQUE INDEX "Watchlist_type_value_global_key"
ON "Watchlist"("type","value")
WHERE "organizationId" IS NULL;
+32 -7
View File
@@ -2248,22 +2248,47 @@ enum WatchlistSeverity {
CRITICAL
}
enum WatchlistAction {
REPORT
BLOCK
}
model Watchlist {
id String @id @unique @default(cuid())
type WatchlistType
// The identifier of the Watchlisted entity (email or domain)
id String @id @unique @default(cuid())
type WatchlistType
value String
description String?
organizationId Int?
action WatchlistAction @default(REPORT)
severity WatchlistSeverity @default(LOW)
createdAt DateTime @default(now())
createdBy User @relation("CreatedWatchlists", onDelete: Cascade, fields: [createdById], references: [id])
createdBy User @relation("CreatedWatchlists", onDelete: SetNull, fields: [createdById], references: [id])
createdById Int
updatedAt DateTime @updatedAt
updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id])
updatedById Int?
severity WatchlistSeverity @default(LOW)
@@unique([type, value])
@@index([type, value])
@@unique([type, value, organizationId])
@@index([type, value, organizationId, action])
}
model BlockedBookingLog {
id String @id @default(uuid())
email String
eventTypeId Int?
organizationId Int?
bookingData Json?
createdAt DateTime @default(now())
watchlistId String?
@@index([organizationId, createdAt])
@@index([email])
@@index([watchlistId])
}
enum BillingPeriod {
@@ -578,7 +578,6 @@ export async function getBookings({
.selectFrom("Attendee")
.select(["Attendee.email"])
.whereRef("BookingSeat.attendeeId", "=", "Attendee.id")
.where("Attendee.email", "=", user.email)
).as("attendee"),
])
.whereRef("BookingSeat.bookingId", "=", "Booking.id")
@@ -1,8 +1,11 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/teamService";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -27,9 +30,17 @@ export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => {
const { quantity, teamId } = input;
if (teamId) {
const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId);
const team = await TeamService.fetchTeamOrThrow(teamId);
if (!adminMembership) {
const permissionService = new PermissionCheckService();
const hasManageBillingPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasManageBillingPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
@@ -1,5 +1,7 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -28,9 +30,17 @@ export const downloadExpenseLogHandler = async ({ ctx, input }: DownloadExpenseL
const { teamId, startDate, endDate } = input;
if (teamId) {
const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId);
const team = await TeamService.fetchTeamOrThrow(teamId);
if (!adminMembership) {
const permissionService = new PermissionCheckService();
const hasManageBillingPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasManageBillingPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
@@ -1,4 +1,7 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { MembershipRole } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -16,9 +19,17 @@ export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions)
const { teamId } = input;
if (teamId) {
const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId);
const team = await TeamService.fetchTeamOrThrow(teamId);
if (!adminMembership) {
const permissionService = new PermissionCheckService();
const hasManageBillingPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasManageBillingPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
@@ -0,0 +1,287 @@
import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { WorkflowActions } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { deleteHandler } from "./delete.handler";
import {
isAuthorized,
removeSmsReminderFieldForEventTypes,
removeAIAgentCallPhoneNumberFieldForEventTypes,
} from "./util";
vi.mock("@calcom/prisma", () => ({
prisma,
}));
vi.mock("@calcom/features/calAIPhone", () => ({
createDefaultAIPhoneServiceProvider: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/workflow", () => ({
WorkflowRepository: {
deleteAllWorkflowReminders: vi.fn(),
},
}));
vi.mock("./util", () => ({
isAuthorized: vi.fn(),
removeSmsReminderFieldForEventTypes: vi.fn(),
removeAIAgentCallPhoneNumberFieldForEventTypes: vi.fn(),
}));
describe("deleteHandler", () => {
const mockCreateDefaultAIPhoneServiceProvider = vi.mocked(createDefaultAIPhoneServiceProvider);
const mockIsAuthorized = vi.mocked(isAuthorized);
const mockRemoveSmsReminderFieldForEventTypes = vi.mocked(removeSmsReminderFieldForEventTypes);
const mockRemoveAIAgentCallPhoneNumberFieldForEventTypes = vi.mocked(
removeAIAgentCallPhoneNumberFieldForEventTypes
);
const mockDeleteAllWorkflowReminders = vi.mocked(WorkflowRepository.deleteAllWorkflowReminders);
const mockUser = {
id: 123,
name: "Test User",
email: "test@example.com",
};
const mockCtx = {
user: mockUser,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("Authorization", () => {
it("should throw UNAUTHORIZED when user is not authorized or workflow not found", async () => {
const workflowId = 1;
prisma.workflow.findUnique.mockResolvedValue({
id: workflowId,
teamId: 456,
userId: 789,
activeOn: [],
activeOnTeams: [],
steps: [],
team: null,
});
mockIsAuthorized.mockResolvedValue(false);
await expect(deleteHandler({ ctx: mockCtx, input: { id: workflowId } })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" })
);
prisma.workflow.findUnique.mockResolvedValue(null);
await expect(deleteHandler({ ctx: mockCtx, input: { id: workflowId } })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" })
);
});
});
describe("Booking field cleanup", () => {
it("should remove both SMS reminder and AI agent phone number fields", async () => {
const workflowId = 1;
const eventTypeIds = [10, 20];
const mockWorkflow = {
id: workflowId,
teamId: null,
userId: mockUser.id,
activeOn: eventTypeIds.map((id) => ({ eventTypeId: id })),
activeOnTeams: [],
steps: [],
team: null,
};
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
mockIsAuthorized.mockResolvedValue(true);
prisma.workflowReminder.findMany.mockResolvedValue([]);
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalledWith({
activeOnToRemove: eventTypeIds,
workflowId: workflowId,
isOrg: false,
});
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalledWith({
activeOnToRemove: eventTypeIds,
workflowId: workflowId,
isOrg: false,
});
});
it("should handle organization workflows correctly", async () => {
const workflowId = 1;
const teamIds = [100, 200];
const mockWorkflow = {
id: workflowId,
teamId: 456,
userId: mockUser.id,
activeOn: [],
activeOnTeams: teamIds.map((id) => ({ teamId: id })),
steps: [],
team: {
isOrganization: true,
},
};
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
mockIsAuthorized.mockResolvedValue(true);
prisma.workflowReminder.findMany.mockResolvedValue([]);
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalledWith({
activeOnToRemove: teamIds,
workflowId: workflowId,
isOrg: true,
});
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalledWith({
activeOnToRemove: teamIds,
workflowId: workflowId,
isOrg: true,
});
});
});
describe("CAL AI phone call cleanup", () => {
let mockAIPhoneService: {
cancelPhoneNumberSubscription: ReturnType<typeof vi.fn>;
deletePhoneNumber: ReturnType<typeof vi.fn>;
deleteAgent: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockAIPhoneService = {
cancelPhoneNumberSubscription: vi.fn(),
deletePhoneNumber: vi.fn(),
deleteAgent: vi.fn(),
};
mockCreateDefaultAIPhoneServiceProvider.mockReturnValue(mockAIPhoneService);
});
it("should cleanup AI phone resources based on subscription status", async () => {
const workflowId = 1;
const mockWorkflow = {
id: workflowId,
teamId: null,
userId: mockUser.id,
activeOn: [],
activeOnTeams: [],
steps: [
{
action: WorkflowActions.CAL_AI_PHONE_CALL,
agent: {
id: "agent-1",
outboundPhoneNumbers: [
{
id: "phone-active",
phoneNumber: "+1111111111",
subscriptionStatus: "ACTIVE",
},
{
id: "phone-null",
phoneNumber: "+2222222222",
subscriptionStatus: null,
},
],
},
},
],
team: null,
};
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
mockIsAuthorized.mockResolvedValue(true);
prisma.workflowReminder.findMany.mockResolvedValue([]);
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
expect(mockAIPhoneService.cancelPhoneNumberSubscription).toHaveBeenCalledWith({
phoneNumberId: "phone-active",
userId: mockUser.id,
});
expect(mockAIPhoneService.deletePhoneNumber).toHaveBeenCalledWith({
phoneNumber: "+2222222222",
userId: mockUser.id,
deleteFromDB: true,
});
expect(mockAIPhoneService.deleteAgent).toHaveBeenCalledWith({
id: "agent-1",
userId: mockUser.id,
teamId: undefined,
});
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalled();
});
});
describe("Workflow deletion flow", () => {
it("should complete full deletion flow successfully", async () => {
const workflowId = 1;
const mockReminders = [
{
id: 1,
workflowStepId: 1,
scheduled: true,
referenceId: "ref-1",
},
];
const mockWorkflow = {
id: workflowId,
teamId: null,
userId: mockUser.id,
activeOn: [{ eventTypeId: 10 }],
activeOnTeams: [],
steps: [],
team: null,
};
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
mockIsAuthorized.mockResolvedValue(true);
prisma.workflowReminder.findMany.mockResolvedValue(mockReminders);
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
const result = await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
expect(prisma.workflowReminder.findMany).toHaveBeenCalledWith({
where: {
workflowStep: {
workflowId: workflowId,
},
scheduled: true,
NOT: {
referenceId: null,
},
},
});
expect(mockDeleteAllWorkflowReminders).toHaveBeenCalledWith(mockReminders);
expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalled();
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalled();
expect(prisma.workflow.deleteMany).toHaveBeenCalledWith({
where: {
id: workflowId,
},
});
expect(result).toEqual({ id: workflowId });
});
});
});
@@ -7,7 +7,11 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TDeleteInputSchema } from "./delete.schema";
import { isAuthorized, removeSmsReminderFieldForEventTypes } from "./util";
import {
isAuthorized,
removeSmsReminderFieldForEventTypes,
removeAIAgentCallPhoneNumberFieldForEventTypes,
} from "./util";
type DeleteOptions = {
ctx: {
@@ -138,6 +142,11 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
: workflowToDelete.activeOn.map((activeOn) => activeOn.eventTypeId);
await removeSmsReminderFieldForEventTypes({ activeOnToRemove, workflowId: workflowToDelete.id, isOrg });
await removeAIAgentCallPhoneNumberFieldForEventTypes({
activeOnToRemove,
workflowId: workflowToDelete.id,
isOrg,
});
// automatically deletes all steps and reminders connected to this workflow
await prisma.workflow.deleteMany({
+1 -2
View File
@@ -2,9 +2,8 @@ import { createPrismock } from "prismock";
import { beforeEach, vi } from "vitest";
import logger from "@calcom/lib/logger";
import * as selects from "@calcom/prisma/selects";
import { Prisma as PrismaType } from "@calcom/prisma/client";
import * as selects from "@calcom/prisma/selects";
vi.stubEnv("DATABASE_URL", "postgresql://user:password@localhost:5432/testdb");