Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608fe1c586 | ||
|
|
4413295b27 | ||
|
|
bcc433ba9a | ||
|
|
2ba571d230 | ||
|
|
4a0a1ef053 | ||
|
|
7e882eedf8 | ||
|
|
224e606439 | ||
|
|
14b3344a41 | ||
|
|
a538ba64d2 | ||
|
|
b3bfad7fea | ||
|
|
f80b45e36a | ||
|
|
f3d60b141f | ||
|
|
5463dc261c |
Vendored
Vendored
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+25
-18
@@ -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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
-20
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+34
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.7.2",
|
||||
"version": "5.7.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
+55
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user