Compare commits

...
15 Commits
Author SHA1 Message Date
GitHub Actions 3bea03f4b1 chore: release v5.4.3
Post release / release (push) Canceled after 0s
2025-06-19 07:57:24 +00:00
Hariom Balhara 19dbde8a3c Add comment (#21920) 2025-06-19 05:55:42 +00:00
Syed Ali Shahbaz a5fe10189f fix: Banner uploader fix error handling and reduce image size jump (#21903)
* improve uploader

* better error handling

* unnecessary check

* more adjustments

* remove logs
2025-06-19 03:35:41 +00:00
Adarsh Tiwari 7075384313 fix:hover on delete action on event-types (#21884) 2025-06-18 21:47:47 +00:00
Udit Takkar 25bf43cddd perf: improve invalid app credential query (#21909) 2025-06-19 02:29:33 +05:30
Udit Takkar 2880a681a8 feat: disable transcription setting in cal video (#21755)
* feat: disable transcription setting in cal video

* fix: type err
2025-06-18 14:36:36 +00:00
bbcd0b7e78 test: add unit tests to various important fns in routing flow (#21902)
* Add unit tests to routing flow

* fix: resolve TypeScript errors in routing forms test files

- Fix mock form structure to match TargetRoutingFormForResponse type
- Add missing properties like selectText, deleted, updatedById to form fields
- Correct route action types to use valid RouteActionType enum values
- Fix response objects to include required label property
- Update mock return values for findTeamMembersMatchingAttributeLogic
- Use proper type assertions for complex route objects

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-06-18 14:14:14 +00:00
Adarsh Tiwari 916ed13bea fix the height of buttons in override list (#21898) 2025-06-18 12:41:28 +00:00
Alex van Andel 11a06324be fix: 'handles OOO correctly' test fail in non-UTC servertime (#21899) 2025-06-18 12:28:32 +01:00
Hariom Balhara bcb340d97e fix: Dry Run for non-pre-render scenario (#21893)
* Fix dry-run non-prerender scenario

* Add test

* Enable logging through cal.embed.logging=1
2025-06-18 10:27:44 +01:00
Somay Chauhanandcubic-dev-ai[bot] aabef47ff1 fix: microsoft oAuth in api/v2 (#21890)
* fix: microsoft oAuth in api/v2

* Update apps/api/v2/src/ee/calendars/services/gcal.service.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update gcal.service.ts

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-18 07:34:42 +00:00
Benny Joo e27de47d9f perf: leverage DB index for user profile fetching (booking page & payment page) (#21814)
* leverage DB index for findByUserIdAndOrgSlug method

* booking view

* payment view

* fix

* fix

* address comment
2025-06-18 09:53:12 +03:00
Benny Joo 7900be3a30 perf: deduplicating DB fetches + leverage unique DB index for team type booking page (#21811)
* wip

* refactor

* refactor

* fix

* optimize

* better

* comment

* better code

* do not use cache for now

* simplify
2025-06-18 09:53:04 +03:00
Joe Au-YeungandOmar López ddc5838c7b Change to OR operator (#21881)
Co-authored-by: Omar López <zomars@me.com>
2025-06-17 23:04:38 +00:00
6a13d7c5c6 fix: update trigger function for RoutingFormResponseField (#21868)
* fix: update trigger function for RoutingFormResponseField

* Update packages/prisma/migrations/20250617123959_update_handle_routing_form_response_fields/migration.sql

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* add reusable function

* handle number type more flexibly

* handle multiselect value flexibly

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-17 20:47:43 +02:00
47 changed files with 1412 additions and 201 deletions
View File
@@ -54,6 +54,23 @@ import {
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
export interface CalendarState {
accessToken: string;
origin: string;
redir?: string;
isDryRun?: boolean;
}
const calendarStateSchema = z.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().optional(),
isDryRun: z
.string()
.optional()
.transform((val) => val === "true"),
});
@Controller({
path: "/v2/calendars",
version: API_VERSIONS_VALUES,
@@ -181,32 +198,31 @@ export class CalendarsController {
@Query("code") code: string,
@Param("calendar") calendar: string
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin, redir, isDryRun } = z
.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().nullish().optional(),
isDryRun: z.string().nullish().optional(),
})
.parse({
let stateObj: CalendarState;
try {
// First try to parse as JSON
stateObj = JSON.parse(state) as CalendarState;
} catch (e) {
// If JSON parsing fails, try URL params
const stateParams = new URLSearchParams(state);
const parsedState = calendarStateSchema.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
redir: stateParams.get("redir") || undefined,
isDryRun: stateParams.get("isDryRun"),
});
stateObj = parsedState;
}
const { accessToken, origin, redir, isDryRun } = stateObj;
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
return await this.outlookService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(
code,
accessToken,
origin,
redir ?? "",
isDryRun === "true"
);
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -1,4 +1,5 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import type { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -65,12 +66,18 @@ export class GoogleCalendarService implements OAuthCalendarApp {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const state: CalendarState = {
accessToken,
origin,
redir,
isDryRun,
};
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
state: JSON.stringify(state),
});
return authUrl;
@@ -1,4 +1,5 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -60,6 +61,13 @@ export class OutlookService implements OAuthCalendarApp {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const state: CalendarState = {
accessToken,
origin,
redir,
isDryRun,
};
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
const params = {
response_type: "code",
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
state: JSON.stringify(state),
};
const query = stringify(params);
+4 -1
View File
@@ -3,6 +3,7 @@ import { stringify } from "querystring";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
@@ -11,13 +12,15 @@ export const getTemporaryOrgRedirect = async ({
redirectType,
eventTypeSlug,
currentQuery,
prismaClient,
}: {
slugs: string[] | string;
redirectType: RedirectType;
eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery;
prismaClient?: PrismaClient;
}) => {
const prisma = (await import("@calcom/prisma")).default;
const prisma = prismaClient ?? (await import("@calcom/prisma")).default;
slugs = slugs instanceof Array ? slugs : [slugs];
log.debug(
`Looking for redirect for`,
@@ -2,9 +2,11 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
orgDomainConfig,
whereClauseForOrgWithSlugOrRequestedSlug,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -30,7 +32,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { req, params, query } = context;
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
@@ -41,6 +43,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: context.query,
prismaClient: prisma,
});
if (redirect) {
@@ -48,43 +51,49 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
}
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
const [orgId, booking] = await Promise.all([
isOrgContext ? getOrgId(currentOrgDomain) : Promise.resolve(null),
rescheduleUid ? getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : Promise.resolve(null),
]);
if (!team || !team.eventTypes?.[0]) {
const team = await getTeamData(teamSlug, orgId);
if (!team) {
return { notFound: true } as const;
}
const eventData = team.eventTypes[0];
const eventData = await getEventTypeData(meetingSlug, team.id);
if (!eventData) {
return { notFound: true } as const;
}
if (rescheduleUid && eventData.disableRescheduling) {
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
}
if (
booking?.status === BookingStatus.CANCELLED &&
!allowRescheduleForCancelledBooking &&
!eventData.allowReschedulingCancelledBookings
) {
return {
redirect: {
permanent: false,
destination: `/team/${teamSlug}/${meetingSlug}`,
},
};
}
const eventTypeId = eventData.id;
const eventHostsUserData = await getUsersData(
team.isPrivate,
eventTypeId,
eventData.hosts.map((h) => h.user)
);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const name = team.parent?.name ?? team.name ?? null;
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
if (
booking?.status === BookingStatus.CANCELLED &&
!allowRescheduleForCancelledBooking &&
!eventData.allowReschedulingCancelledBookings
) {
return {
redirect: {
permanent: false,
destination: `/team/${teamSlug}/${meetingSlug}`,
},
};
}
}
const eventHostsUserData = getEventHosts(
team.isPrivate,
eventData.hosts.map((h) => h.user),
eventData.users ?? []
);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
@@ -95,11 +104,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Handle string[] type from query params
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
? crmContactOwnerRecordType[0]
: crmContactOwnerRecordType;
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
@@ -122,7 +129,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const featureRepo = new FeaturesRepository();
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
@@ -172,116 +178,149 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
};
const getTeamWithEventsData = async (
teamSlug: string,
meetingSlug: string,
isValidOrgDomain: boolean,
currentOrgDomain: string | null
) => {
const getOrgId = async (orgSlug: string): Promise<number | null> => {
const org = await prisma.team.findFirst({
where: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
select: { id: true },
});
return org?.id ?? null;
};
const getTeamData = async (teamSlug: string, orgId: number | null) => {
const teamSelectFields = {
id: true,
isPrivate: true,
hideBranding: true,
logoUrl: true,
name: true,
slug: true,
isOrganization: true,
parent: {
select: {
slug: true,
name: true,
bannerUrl: true,
logoUrl: true,
hideBranding: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
},
},
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
};
if (orgId !== null) {
const publishedTeam = await prisma.team.findUnique({
where: {
slug_parentId: {
slug: teamSlug,
parentId: orgId,
},
isOrganization: false,
},
select: teamSelectFields,
});
if (publishedTeam) return publishedTeam;
}
return await prisma.team.findFirst({
where: {
...getSlugOrRequestedSlug(teamSlug),
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
parentId: orgId ?? null,
isOrganization: false,
OR: [
{ slug: teamSlug },
{
metadata: {
path: ["requestedSlug"],
equals: teamSlug,
},
},
],
},
select: teamSelectFields,
orderBy: {
slug: { sort: "asc", nulls: "last" },
},
});
};
const getEventTypeData = async (meetingSlug: string, teamId: number) => {
return await prisma.eventType.findUnique({
where: {
// Use the EventType_teamId_slug_key unique index
teamId_slug: {
teamId: teamId,
slug: meetingSlug,
},
},
select: {
id: true,
isPrivate: true,
hideBranding: true,
parent: {
title: true,
isInstantEvent: true,
schedulingType: true,
metadata: true,
length: true,
hidden: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
interfaceLanguage: true,
hosts: {
take: 3,
select: {
slug: true,
user: {
select: {
name: true,
username: true,
},
},
},
},
// Include users for when hosts is empty
users: {
take: 1,
select: {
username: true,
name: true,
bannerUrl: true,
logoUrl: true,
hideBranding: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
},
},
logoUrl: true,
name: true,
slug: true,
eventTypes: {
where: {
slug: meetingSlug,
},
select: {
id: true,
title: true,
isInstantEvent: true,
schedulingType: true,
metadata: true,
length: true,
hidden: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
interfaceLanguage: true,
hosts: {
take: 3,
select: {
user: {
select: {
name: true,
username: true,
email: true,
},
},
},
},
},
},
isOrganization: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
},
});
};
const getUsersData = async (
const getEventHosts = (
isPrivateTeam: boolean,
eventTypeId: number,
hosts: Pick<User, "username" | "name">[],
users: Pick<User, "username" | "name">[]
) => {
if (!isPrivateTeam && users.length > 0) {
return users
if (isPrivateTeam) {
return [];
}
if (hosts.length > 0) {
return hosts
.filter((user) => user.username)
.map((user) => ({
username: user.username ?? "",
name: user.name ?? "",
}));
}
if (!isPrivateTeam && users.length === 0) {
const { users: data } = await prisma.eventType.findUniqueOrThrow({
where: { id: eventTypeId },
select: {
users: {
take: 1,
select: {
username: true,
name: true,
},
},
},
});
return data.length > 0
? [
{
username: data[0].username ?? "",
name: data[0].name ?? "",
},
]
: [];
if (users.length > 0) {
return [
{
username: users[0].username ?? "",
name: users[0].name ?? "",
},
];
}
return [];
@@ -23,6 +23,8 @@ type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
enableAutomaticTranscription: boolean;
disableTranscriptionForGuests: boolean;
disableTranscriptionForOrganizer: boolean;
};
const shouldEnableRecordButton = ({
@@ -57,6 +59,25 @@ const shouldEnableAutomaticTranscription = ({
return !!calVideoSettings.enableAutomaticTranscription;
};
const shouldEnableTranscriptionButton = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan) return false;
if (!calVideoSettings) return true;
if (isOrganizer) {
return !calVideoSettings.disableTranscriptionForOrganizer;
}
return !calVideoSettings.disableTranscriptionForGuests;
};
const checkIfUserIsHost = async ({
booking,
sessionUserId,
@@ -222,6 +243,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
});
const showTranscriptionButton = shouldEnableTranscriptionButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
});
return {
props: {
@@ -245,6 +271,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
enableAutomaticTranscription,
showTranscriptionButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
@@ -234,7 +234,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventTypeId: eventType.id,
team: eventType.team,
owner: eventType.users[0] ?? null,
orgSlug: currentOrgDomain,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
profile,
eventType,
@@ -653,7 +653,7 @@ export const InfiniteEventTypeList = ({
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
className="w-full rounded-t-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
+7 -1
View File
@@ -73,9 +73,11 @@ export interface DailyCustomTrayButton {
export const CalAiTranscribe = ({
showRecordingButton,
enableAutomaticTranscription,
showTranscriptionButton,
}: {
showRecordingButton: boolean;
enableAutomaticTranscription: boolean;
showTranscriptionButton: boolean;
}) => {
const daily = useDaily();
const { t } = useLocale();
@@ -101,7 +103,11 @@ export const CalAiTranscribe = ({
recording,
}
: {}),
transcription,
...(showTranscriptionButton
? {
transcription,
}
: {}),
});
};
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
overrideName,
showRecordingButton,
enableAutomaticTranscription,
showTranscriptionButton,
rediectAttendeeToOnExit,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
@@ -80,12 +81,16 @@ export default function JoinCall(props: PageProps) {
},
}
: {}),
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
...(showTranscriptionButton
? {
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
}
: {}),
},
}),
});
@@ -114,6 +119,7 @@ export default function JoinCall(props: PageProps) {
<CalAiTranscribe
showRecordingButton={showRecordingButton}
enableAutomaticTranscription={enableAutomaticTranscription}
showTranscriptionButton={showTranscriptionButton}
/>
</div>
<div style={{ zIndex: 2, position: "relative" }}>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.2",
"version": "5.4.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -49,6 +49,8 @@
"disable_recording_for_guests": "Disable recording for guests",
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
"disable_recording_for_organizer": "Disable recording for organizer",
"disable_transcription_for_guests": "Disable transcription for guests",
"disable_transcription_for_organizer": "Disable transcription for organizer",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
@@ -3251,5 +3253,6 @@
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
"duration_min_error": "Duration must be at least {{min}} minute(s)",
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -98,7 +98,8 @@ export default class GoogleCalendarService implements Calendar {
organizer: true,
// Tried changing the display name to the user but GCal will not let you do that. It will only display the name of the external calendar. Leaving this in just incase it works in the future.
displayName: event.organizer.name,
email: hostExternalCalendarId ?? selectedHostDestinationCalendar?.externalId ?? event.organizer.email,
// We use || instead of ?? here to handle empty strings
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
},
...eventAttendees,
];
@@ -0,0 +1,309 @@
import "@calcom/lib/__mocks__/logger";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
import isRouter from "../lib/isRouter";
import routerGetCrmContactOwnerEmail from "./crmRouting/routerGetCrmContactOwnerEmail";
import type { TargetRoutingFormForResponse } from "./formSubmissionUtils";
import { onSubmissionOfFormResponse } from "./formSubmissionUtils";
import { handleResponse } from "./handleResponse";
vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({
findTeamMembersMatchingAttributeLogic: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/formResponse", () => ({
RoutingFormResponseRepository: {
recordQueuedFormResponse: vi.fn(),
recordFormResponse: vi.fn(),
},
}));
vi.mock("./crmRouting/routerGetCrmContactOwnerEmail", () => ({
default: vi.fn(),
}));
vi.mock("./formSubmissionUtils", () => ({
onSubmissionOfFormResponse: vi.fn(),
}));
vi.mock("../lib/isRouter", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/lib/sentryWrapper", () => ({
withReporting: (fn: unknown) => fn,
}));
const mockForm: TargetRoutingFormForResponse = {
id: "form-id",
name: "Test Form",
fields: [
{
id: "name",
label: "Name",
type: "text" as const,
required: true,
identifier: "name",
placeholder: undefined,
selectText: undefined,
deleted: false,
},
{
id: "email",
label: "Email",
type: "email" as const,
required: true,
identifier: "email",
placeholder: undefined,
selectText: undefined,
deleted: false,
},
{
id: "guests",
label: "Guests",
type: "text" as const,
required: false,
identifier: "guests",
placeholder: undefined,
selectText: undefined,
deleted: false,
},
],
routes: [],
userId: 1,
teamId: 1,
user: {
id: 1,
email: "test@example.com",
},
team: {
parentId: 2,
},
createdAt: "2023-01-01T00:00:00.000Z",
updatedAt: "2023-01-01T00:00:00.000Z",
description: null,
disabled: false,
settings: {
emailOwnerOnSubmission: false,
},
connectedForms: [],
routers: [],
teamMembers: [],
position: 0,
updatedById: null,
};
const mockResponse: z.infer<typeof ZResponseInputSchema>["response"] = {
name: { value: "John Doe", label: "Name" },
email: { value: "john.doe@example.com", label: "Email" },
};
describe("handleResponse", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("should throw a TRPCError for missing required fields", async () => {
await expect(
handleResponse({
response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing
form: mockForm,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
})
).rejects.toThrow(/Missing required fields Name/);
});
it("should throw a TRPCError for invalid email", async () => {
await expect(
handleResponse({
response: {
name: { value: "John Doe", label: "Name" },
email: { value: "invalid-email", label: "Email" },
},
form: mockForm,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
})
).rejects.toThrow(/Invalid value for fields 'Email' with value 'invalid-email' should be valid email/);
});
it("should record form response when not in preview and not queued", async () => {
const dbFormResponse = {
id: 1,
formId: mockForm.id,
response: mockResponse,
chosenRouteId: null,
formFillerId: "user1",
routedToBookingUid: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse);
const result = await handleResponse({
response: mockResponse,
form: mockForm,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
});
expect(RoutingFormResponseRepository.recordFormResponse).toHaveBeenCalledWith({
formId: mockForm.id,
response: mockResponse,
chosenRouteId: null,
});
expect(onSubmissionOfFormResponse).toHaveBeenCalledWith({
form: mockForm,
formResponseInDb: dbFormResponse,
chosenRouteAction: null,
});
expect(result.formResponse).toEqual(dbFormResponse);
expect(result.queuedFormResponse).toBeNull();
});
it("should queue form response when queueFormResponse is true", async () => {
const queuedResponse = {
id: "queued-id",
formId: mockForm.id,
response: mockResponse,
chosenRouteId: null,
actualResponseId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(RoutingFormResponseRepository.recordQueuedFormResponse).mockResolvedValue(queuedResponse);
const result = await handleResponse({
response: mockResponse,
form: mockForm,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
queueFormResponse: true,
});
expect(RoutingFormResponseRepository.recordQueuedFormResponse).toHaveBeenCalledWith({
formId: mockForm.id,
response: mockResponse,
chosenRouteId: null,
});
expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
expect(result.queuedFormResponse).toEqual(queuedResponse);
expect(result.formResponse).toBeNull();
});
describe("Preview mode", () => {
it("should send formResponse with id=0 and quueueFormResponse isn't true", async () => {
const result = await handleResponse({
response: mockResponse,
form: mockForm,
formFillerId: "user1",
chosenRouteId: null,
isPreview: true,
});
expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
expect(result.isPreview).toBe(true);
expect(result.formResponse).toBeDefined();
expect(result.formResponse?.id).toBe(0);
});
it("should send queuedFormResponse with id=00000000-0000-0000-0000-000000000000 when queueFormResponse is true", async () => {
const result = await handleResponse({
response: mockResponse,
form: mockForm,
formFillerId: "user1",
chosenRouteId: null,
isPreview: true,
queueFormResponse: true,
});
expect(RoutingFormResponseRepository.recordQueuedFormResponse).not.toHaveBeenCalled();
expect(result.isPreview).toBe(true);
expect(result.queuedFormResponse).toBeDefined();
expect(result.queuedFormResponse?.id).toBe("00000000-0000-0000-0000-000000000000");
expect(result.formResponse).toBeUndefined();
});
});
it("should handle chosen route and call routing logic", async () => {
const chosenRoute = {
id: "route1",
queryValue: { type: "group", children1: {} } as any,
action: {
type: "customPageMessage" as const,
value: "Thank you for your submission!",
},
attributeRoutingConfig: null,
attributesQueryValue: { type: "group", children1: {} } as any,
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
isFallback: false,
};
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
email: "owner@example.com",
recordType: "contact",
crmAppSlug: "hubspot",
});
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
checkedFallback: false,
fallbackAttributeLogicBuildingWarnings: [],
mainAttributeLogicBuildingWarnings: [],
timeTaken: {
ttGetAttributesForLogic: 50,
},
});
const result = await handleResponse({
response: mockResponse,
form: formWithRoute,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: false,
});
expect(routerGetCrmContactOwnerEmail).toHaveBeenCalled();
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalled();
expect(result.crmContactOwnerEmail).toBe("owner@example.com");
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
});
it("should throw error if chosen route is a router", async () => {
const chosenRoute = {
id: "route1",
name: "A router",
description: "Router description",
isRouter: true as const,
routes: [],
};
vi.mocked(isRouter).mockReturnValue(true);
const formWithRoute: TargetRoutingFormForResponse = {
...mockForm,
routes: [chosenRoute as any],
};
await expect(
handleResponse({
response: mockResponse,
form: formWithRoute,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: false,
})
).rejects.toThrow(/Chosen route is a router/);
});
});
@@ -3,6 +3,15 @@ import slugify from "@calcom/lib/slugify";
import type { FormResponse, NonRouterRoute, Field } from "../types/types";
import getFieldIdentifier from "./getFieldIdentifier";
/**
* Substitues variables in the target URL identified by routeValue with values from response
* e.g. {firstName} is replaced with value of the field with identifier firstName
*
* @param routeValue - The target URL with variables to be substituted
* @param response - The form response containing the values to be substituted
* @param fields - The fields of the form
* @returns The URL with variables substituted
*/
export const substituteVariables = (
routeValue: NonRouterRoute["action"]["value"],
response: FormResponse,
@@ -328,4 +328,26 @@ describe("getUrlSearchParamsToForward", () => {
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
describe("Dry Run", () => {
it("should add cal.routingFormResponseId=0 when formResponseId is 0", () => {
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
const expectedParams = {
"cal.routingFormResponseId": "0",
query1: "value1",
query2: "value2",
};
const result = getUrlSearchParamsToForward({
formResponse: {},
fields: [],
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 0,
queuedFormResponseId: null,
attributeRoutingConfig: null,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
});
});
@@ -124,7 +124,9 @@ export function getUrlSearchParamsToForward({
...(teamMembersMatchingAttributeLogic
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
: null),
...(formResponseId ? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) } : null),
...(typeof formResponseId === "number"
? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) }
: null),
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
...attributeRoutingConfigParams,
...(reroutingFormResponses
@@ -0,0 +1,168 @@
import "@calcom/lib/__mocks__/logger";
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import { _onFormSubmission } from "./utils";
// Mock dependencies
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
default: vi.fn(() => Promise.resolve(1)),
}));
vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({
default: vi.fn(() => Promise.resolve([])),
}));
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
sendGenericWebhookPayload: vi.fn(() => Promise.resolve()),
}));
vi.mock("@calcom/features/tasker", () => {
const tasker = {
create: vi.fn(() => Promise.resolve()),
};
return { default: Promise.resolve(tasker) };
});
const mockSendEmail = vi.fn(() => Promise.resolve());
const mockResponseEmailConstructor = vi.fn();
vi.mock("../emails/templates/response-email", () => ({
default: class MockResponseEmail {
sendEmail = mockSendEmail;
constructor(...args: unknown[]) {
mockResponseEmailConstructor(...args);
}
},
}));
describe("_onFormSubmission", () => {
const mockForm = {
id: "form-1",
name: "Test Form",
fields: [
{ id: "field-1", identifier: "email", label: "Email", type: "email" },
{ id: "field-2", identifier: "name", label: "Name", type: "text" },
],
user: { id: 1, email: "test@example.com" },
teamId: null,
settings: { emailOwnerOnSubmission: true },
};
const mockResponse = {
"field-1": { label: "Email", value: "test@response.com" },
"field-2": { label: "Name", value: "Test Name" },
};
const responseId = 123;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Webhooks", () => {
it("should call FORM_SUBMITTED webhooks", async () => {
vi.mocked(getWebhooks).mockResolvedValueOnce([{ id: "wh-1", secret: "secret" } as any]);
await _onFormSubmission(mockForm as any, mockResponse, responseId);
expect(getWebhooks).toHaveBeenCalledWith({
userId: 1,
teamId: null,
orgId: 1,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
});
expect(sendGenericWebhookPayload).toHaveBeenCalledTimes(1);
});
it("should schedule FORM_SUBMITTED_NO_EVENT webhooks via tasker", async () => {
const tasker = await (await import("@calcom/features/tasker")).default;
const mockWebhook = { id: "wh-no-event-1", secret: "secret" };
const chosenAction = { type: "customPageMessage" as const, value: "test" };
vi.mocked(getWebhooks).mockImplementation(async (options) => {
if (options.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT) {
return [mockWebhook as any];
}
return [];
});
await _onFormSubmission(mockForm as any, mockResponse, responseId, chosenAction);
expect(getWebhooks).toHaveBeenCalledWith(
expect.objectContaining({
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
})
);
expect(tasker.create).toHaveBeenCalledWith(
"triggerFormSubmittedNoEventWebhook",
{
responseId,
form: mockForm,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
},
name: { value: "Test Name", response: "Test Name" },
},
redirect: chosenAction,
webhook: mockWebhook,
},
{ scheduledAt: expect.any(Date) }
);
});
});
describe("Response Email", () => {
it("should send response email to team members for a team form", async () => {
const teamForm = {
...mockForm,
teamId: 1,
userWithEmails: ["team-member1@example.com", "team-member2@example.com"],
user: { id: 1, email: "test@example.com" },
};
await _onFormSubmission(teamForm as any, mockResponse, responseId);
expect(mockResponseEmailConstructor).toHaveBeenCalledWith({
form: teamForm,
toAddresses: ["team-member1@example.com", "team-member2@example.com"],
orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]],
});
expect(mockSendEmail).toHaveBeenCalled();
});
it("should send response email to owner when enabled", async () => {
const ownerForm = {
...mockForm,
settings: { emailOwnerOnSubmission: true },
};
await _onFormSubmission(ownerForm as any, mockResponse, responseId);
expect(mockResponseEmailConstructor).toHaveBeenCalledWith({
form: ownerForm,
toAddresses: [ownerForm.user.email],
orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]],
});
expect(mockSendEmail).toHaveBeenCalled();
});
it("should not send response email to owner when disabled", async () => {
const ownerForm = {
...mockForm,
settings: { emailOwnerOnSubmission: false },
};
await _onFormSubmission(ownerForm as any, mockResponse, responseId);
expect(mockResponseEmailConstructor).not.toHaveBeenCalled();
expect(mockSendEmail).not.toHaveBeenCalled();
});
});
});
@@ -85,7 +85,7 @@ export function getFieldResponse({
* Not called in preview mode or dry run mode
* It takes care of sending webhooks and emails for form submissions
*/
async function _onFormSubmission(
export async function _onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
"fields"
+5
View File
@@ -156,6 +156,11 @@ Cal.config = Cal.config || {};
Cal.config.forwardQueryParams=true
```
#### Enabling Logging
You can enable logging for the embed by adding the `cal.embed.logging=1` query parameter to the URL of the page where the embed is placed. This is useful for debugging issues with the embed. It will log all important things in parent as well as in the iframe(i.e. child)
For example, if your page is `https.example.com/contact`, you can enable logging by visiting `https.example.com/contact?cal.embed.logging=1`.
### Advanced Features
#### Routing Prerendering System
+4 -2
View File
@@ -349,8 +349,10 @@ export class Cal {
urlInstance.searchParams.set("embed", this.namespace);
if (calConfig.debug) {
urlInstance.searchParams.set("debug", `${calConfig.debug}`);
const pageParams = this.getQueryParamsFromPage();
// cal.embed.logging=1 enabled logging in parent and by setting debug=true in iframe, it enables logging in iframe(child) as well
if (calConfig.debug || pageParams["cal.embed.logging"] === "1") {
urlInstance.searchParams.set("debug", "true");
}
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
@@ -156,7 +156,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
let attendeeEmail: string | null = null;
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
if (!theBooking) {
const bookingSeat = await prisma.bookingSeat.findFirst({
const bookingSeat = await prisma.bookingSeat.findUnique({
where: {
referenceUid: uid,
},
@@ -197,6 +197,9 @@ async function createOrganizationWithNonExistentUserAsOwner({
);
const owner = await findUserToBeOrgOwner(email);
if (!owner) {
// Can happen when the organization was created earlier and the webhook had failed and when the webhook got fired again in next subscription update, then the email was deleted already
// The fix would be to change the email in Onboarding record to new owner of the organization
// TODO: Identify the owner of the organization from Membership table and use that email instead here.
throw new Error(`Org exists but owner could not be found for email: ${email}`);
}
return { organization, owner };
@@ -183,6 +183,11 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
const mutation = trpc.viewer.organizations.update.useMutation({
onError: (err) => {
// Handle JSON parsing errors from body size limit exceeded
if (err.message.includes("Unexpected token") && err.message.includes("Body excee")) {
showToast(t("converted_image_size_limit_exceed"), "error");
return;
}
showToast(err.message, "error");
},
onSuccess: async (res) => {
@@ -1,7 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
import prisma from "@calcom/prisma";
@@ -18,7 +18,7 @@ const querySchema = z.object({
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { uid } = querySchema.parse(context.query);
const { currentOrgDomain } = orgDomainConfig(context.req);
const session = await getServerSession({ req: context.req });
const rawPayment = await prisma.payment.findUnique({
where: {
@@ -59,7 +59,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeId: eventType.id,
team: eventType.team,
owner: eventType.users[0] ?? null,
orgSlug: currentOrgDomain,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
};
@@ -388,6 +388,39 @@ const Locations: React.FC<LocationsProps> = ({
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForGuests"
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_guests")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
/>
);
}}
/>
)}
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForOrganizer"
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_organizer")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
/>
);
}}
/>
)}
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={eventType.calVideoSettings?.redirectUrlOnExit || ""}
@@ -156,6 +156,8 @@ export type FormValues = {
disableRecordingForOrganizer?: boolean;
disableRecordingForGuests?: boolean;
enableAutomaticTranscription?: boolean;
disableTranscriptionForGuests?: boolean;
disableTranscriptionForOrganizer?: boolean;
redirectUrlOnExit?: string;
};
maxActiveBookingPerBookerOfferReschedule: boolean;
@@ -106,7 +106,7 @@ const DateOverrideList = ({
<DialogTrigger asChild>
<Button
tooltip={t("edit")}
className="text-default"
className="text-default h-5"
color="minimal"
variant="icon"
StartIcon="pencil"
@@ -116,7 +116,7 @@ const DateOverrideList = ({
/>
<Tooltip content="Delete">
<Button
className="text-default"
className="text-default h-5"
data-testid="delete-button"
title={t("date_overrides_delete_on_date", {
date: new Intl.DateTimeFormat(i18n.language, {
+4 -4
View File
@@ -545,13 +545,13 @@ describe("buildDateRanges", () => {
});
expect(dateRanges[0]).toEqual({
start: dayjs("2023-06-13T14:00:00Z").tz(timeZone),
end: dayjs("2023-06-13T19:00:00Z").tz(timeZone),
start: dayjs.utc("2023-06-13T14:00:00Z").tz(timeZone),
end: dayjs.utc("2023-06-13T19:00:00Z").tz(timeZone),
});
expect(dateRanges[1]).toEqual({
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
start: dayjs.utc("2023-06-14T12:00:00Z").tz(timeZone),
end: dayjs.utc("2023-06-14T21:00:00Z").tz(timeZone),
});
expect(oooExcludedDateRanges.length).toBe(1);
expect(oooExcludedDateRanges[0]).toEqual({
+3 -6
View File
@@ -132,12 +132,9 @@ export function processDateOverride({
};
}
// This function processes out-of-office dates and returns a date range for each OOO date.
function processOOO(outOfOffice: Dayjs, timeZone: string) {
const utcOffset = outOfOffice.tz(timeZone).utcOffset();
const utcDate = outOfOffice.subtract(utcOffset, "minute");
const OOOdate = utcDate.tz(timeZone);
const OOOdate = outOfOffice.tz(timeZone, true);
return {
start: OOOdate,
end: OOOdate,
@@ -171,7 +168,7 @@ export function buildDateRanges({
}, [])
);
const OOOdates = outOfOffice
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs(outOfOffice), timeZone))
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone))
: [];
const groupedOOO = groupByDate(OOOdates);
+4 -6
View File
@@ -24,8 +24,6 @@ type UserWithProfile = UserWithoutProfile & {
profile: Profile | null;
};
type OrgSlug = string | null;
/**
* Determines if branding should be hidden by checking entity and organization settings
*/
@@ -79,12 +77,12 @@ export async function shouldHideBrandingForEvent({
eventTypeId,
team,
owner,
orgSlug,
organizationId,
}: {
eventTypeId: number;
team: Team | null;
owner: UserWithoutProfile | null;
orgSlug: OrgSlug;
organizationId: number | null;
}) {
let ownerProfile = null;
if (team) {
@@ -94,10 +92,10 @@ export async function shouldHideBrandingForEvent({
});
} else if (owner) {
// Needed only for User events, not for Team events
ownerProfile = orgSlug
ownerProfile = organizationId
? await ProfileRepository.findByUserIdAndOrgSlug({
userId: owner.id,
orgSlug,
organizationId,
})
: null;
+280
View File
@@ -0,0 +1,280 @@
import "@calcom/lib/__mocks__/logger";
import type { GetServerSidePropsContext } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { getRoutedUrl } from "./getRoutedUrl";
// Mock dependencies
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
vi.mock("@calcom/lib/server/repository/routingForm");
vi.mock("@calcom/lib/server/repository/user");
vi.mock("@calcom/features/ee/organizations/lib/orgDomains");
vi.mock("@calcom/features/routing-forms/lib/isAuthorizedToViewForm");
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore");
vi.mock("@calcom/app-store/routing-forms/lib/processRoute");
vi.mock("@calcom/app-store/routing-forms/lib/substituteVariables");
vi.mock("@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward");
vi.mock("@calcom/app-store/routing-forms/getEventTypeRedirectUrl");
vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({
enrichFormWithMigrationData: vi.fn((form) => form),
}));
vi.mock("@calcom/lib/sentryWrapper", () => ({
withReporting: (fn: unknown) => fn,
}));
const mockForm = {
id: "form-id",
user: { id: 1, name: "Test User" },
team: null,
name: "Test Form",
fields: [],
routes: [],
userId: 1,
teamId: null,
createdAt: new Date(),
updatedAt: new Date(),
description: null,
enabled: true,
isDefault: false,
fieldsOrder: [],
position: 0,
slug: "test-form",
};
const mockSerializableForm = {
id: "form-id",
fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }],
routes: [],
user: { id: 1 },
};
const mockContext = (
query: Record<string, unknown>,
url = "/link/form-id"
): Pick<GetServerSidePropsContext, "query" | "req"> => ({
query: { form: "form-id", ...query },
req: { url },
});
describe("getRoutedUrl", () => {
beforeEach(() => {
vi.resetAllMocks();
// Provide default mock implementations
vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null });
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
vi.mocked(UserRepository.enrichUserWithItsProfile).mockImplementation(async ({ user }) => user);
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true);
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
vi.mocked(findMatchingRoute).mockReturnValue(null);
vi.mocked(handleResponse).mockResolvedValue({
teamMembersMatchingAttributeLogic: null,
formResponse: { id: 1 },
queuedFormResponse: null,
attributeRoutingConfig: null,
timeTaken: {},
crmContactOwnerEmail: null,
crmContactOwnerRecordType: null,
crmAppSlug: null,
isPreview: false,
});
vi.mocked(substituteVariables).mockImplementation((value) => value);
vi.mocked(getUrlSearchParamsToForward).mockReturnValue(new URLSearchParams());
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockImplementation(
({ eventTypeRedirectUrl }) => eventTypeRedirectUrl
);
vi.mocked(getResponseToStore).mockReturnValue({ email: "test@cal.com" });
});
it("should return notFound if query params are invalid", async () => {
const context = {
query: { wrong_param: "value" }, // 'form' is missing
req: { url: "/link/form-id" },
};
// @ts-expect-error we are testing invalid input
const result = await getRoutedUrl(context);
expect(result).toEqual({ notFound: true });
});
it("should return notFound if form is not found", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
const context = mockContext({});
const result = await getRoutedUrl(context);
expect(result).toEqual({ notFound: true });
expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id");
});
it("should return notFound if user is not authorized", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(false);
const context = mockContext({});
const result = await getRoutedUrl(context);
expect(result).toEqual({ notFound: true });
expect(isAuthorizedToViewFormOnOrgDomain).toHaveBeenCalledWith({
user: mockForm.user,
team: mockForm.team,
currentOrgDomain: null,
});
});
it("should throw an error if no matching route is found", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
vi.mocked(findMatchingRoute).mockReturnValue(null);
const context = mockContext({});
await expect(getRoutedUrl(context)).rejects.toThrow("No matching route could be found");
expect(findMatchingRoute).toHaveBeenCalledWith(
expect.objectContaining({
form: mockSerializableForm,
})
);
});
it("should return props with a message for customPageMessage action", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
const message = "This is a custom message";
const mockRoute = { id: "route1", action: { type: "customPageMessage", value: message } };
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
const context = mockContext({});
const result = await getRoutedUrl(context);
expect(result).toEqual({
props: {
isEmbed: false,
form: mockSerializableForm,
message: message,
errorMessage: null,
},
});
expect(handleResponse).toHaveBeenCalledWith(
expect.objectContaining({
form: mockSerializableForm,
})
);
expect(findMatchingRoute).toHaveBeenCalledWith(
expect.objectContaining({
form: mockSerializableForm,
})
);
});
it("should return a redirect for eventTypeRedirectUrl action and substitute variables", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
const redirectUrl = "test-user/30min/{email}";
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
const substitutedUrl = "test-user/30min/test@cal.com";
vi.mocked(substituteVariables).mockReturnValue(substitutedUrl);
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${substitutedUrl}`);
const context = mockContext({ email: "test@cal.com" });
const result = await getRoutedUrl(context);
expect(substituteVariables).toHaveBeenCalledWith(
redirectUrl,
{ email: "test@cal.com" },
mockSerializableForm.fields
);
expect(getUrlSearchParamsToForward).toHaveBeenCalledWith(
expect.objectContaining({
searchParams: expect.any(URLSearchParams),
formResponse: { email: "test@cal.com" },
fields: mockSerializableForm.fields,
})
);
expect(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).toHaveBeenCalledWith(
expect.objectContaining({
eventTypeRedirectUrl: substitutedUrl,
})
);
expect(result).toEqual({
redirect: {
destination: `/${substitutedUrl}`,
permanent: false,
},
});
});
it("should return a redirect for externalRedirectUrl action", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
const redirectUrl = "https://example.com";
const mockRoute = { id: "route1", action: { type: "externalRedirectUrl", value: redirectUrl } };
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
const context = mockContext({ email: "test@cal.com" });
const result = await getRoutedUrl(context);
const searchParams = new URLSearchParams({
...(context.query as Record<string, string>),
"cal.action": "externalRedirectUrl",
});
expect(result).toEqual({
redirect: {
destination: `${redirectUrl}?${searchParams.toString()}`,
permanent: false,
},
});
expect(findMatchingRoute).toHaveBeenCalledWith(
expect.objectContaining({
form: mockSerializableForm,
})
);
});
it("should call handleResponse with correct response prop obtained from getResponseToStore", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
const redirectUrl = "test-user/30min";
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
vi.mocked(getResponseToStore).mockReturnValue({ "xxx-xxx": { value: "test@cal.com" } } as never);
const context = mockContext({ email: "test@cal.com" });
await getRoutedUrl(context);
expect(getResponseToStore).toHaveBeenCalledWith({
formFields: mockSerializableForm.fields,
fieldsResponses: { email: "test@cal.com" },
});
expect(handleResponse).toHaveBeenCalledWith(
expect.objectContaining({ response: { "xxx-xxx": { value: "test@cal.com" } } })
);
});
describe("Dry Run", () => {
it("should call handleResponse with isPreview:true", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
const redirectUrl = "test-user/30min";
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
const context = mockContext({ "cal.isBookingDryRun": "true" });
await getRoutedUrl(context);
expect(handleResponse).toHaveBeenCalledWith(
expect.objectContaining({
isPreview: true,
})
);
});
});
});
@@ -394,6 +394,8 @@ export class BookingRepository {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticTranscription: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
},
},
@@ -15,6 +15,8 @@ export class CalVideoSettingsRepository {
calVideoSettings: {
disableRecordingForGuests?: boolean | null;
disableRecordingForOrganizer?: boolean | null;
disableTranscriptionForGuests?: boolean | null;
disableTranscriptionForOrganizer?: boolean | null;
enableAutomaticTranscription?: boolean | null;
redirectUrlOnExit?: string | null;
};
@@ -25,6 +27,8 @@ export class CalVideoSettingsRepository {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
updatedAt: new Date(),
},
@@ -32,6 +36,8 @@ export class CalVideoSettingsRepository {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
eventTypeId,
},
@@ -698,6 +698,8 @@ export class EventTypeRepository {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticTranscription: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
},
},
+14 -3
View File
@@ -665,9 +665,20 @@ export class ProfileRepository {
return normalizeProfile(profile);
}
static async findByUserIdAndOrgSlug({ userId, orgSlug }: { userId: number; orgSlug: string }) {
const profile = await prisma.profile.findFirst({
where: { userId, organization: { slug: orgSlug } },
static async findByUserIdAndOrgSlug({
userId,
organizationId,
}: {
userId: number;
organizationId: number;
}) {
const profile = await prisma.profile.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
include: {
organization: {
select: organizationSelect,
@@ -177,6 +177,8 @@ export const useEventTypeForm = ({
disableRecordingForOrganizer: z.boolean().nullable(),
disableRecordingForGuests: z.boolean().nullable(),
enableAutomaticTranscription: z.boolean().nullable(),
disableTranscriptionForGuests: z.boolean().nullable(),
disableTranscriptionForOrganizer: z.boolean().nullable(),
})
.optional()
.nullable(),
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CalVideoSettings" ADD COLUMN "disableTranscriptionForGuests" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableTranscriptionForOrganizer" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,207 @@
-- Shared function that contains the core processing logic
CREATE OR REPLACE FUNCTION _process_routing_form_response_fields(response_id integer, response_data jsonb, form_id text)
RETURNS void AS $$
DECLARE
form_fields jsonb;
field_record jsonb;
response_field jsonb;
field_type text;
BEGIN
-- Validate response_data exists and is a valid JSON object
IF response_data IS NULL OR jsonb_typeof(response_data) != 'object' THEN
RAISE WARNING 'Invalid response data for id %. Type: %', response_id, COALESCE(jsonb_typeof(response_data), 'null');
RETURN;
END IF;
-- Get the fields from App_RoutingForms_Form
SELECT fields::jsonb INTO form_fields
FROM "App_RoutingForms_Form"
WHERE id = form_id;
-- Delete existing entries for this response
DELETE FROM "RoutingFormResponseField"
WHERE "responseId" = response_id;
-- Exit early if form_fields is NULL or not an array
IF form_fields IS NULL OR jsonb_typeof(form_fields) != 'array' THEN
RAISE WARNING 'form_fields is NULL or not an array for formId %, skipping processing', form_id;
RETURN;
END IF;
-- Iterate through each field in the response
FOR field_record IN SELECT * FROM jsonb_array_elements(form_fields)
LOOP
BEGIN
-- Validate field_record has required properties
IF field_record->>'id' IS NULL THEN
RAISE WARNING 'Field record is missing id property, skipping field';
CONTINUE;
END IF;
IF field_record->>'type' IS NULL THEN
RAISE WARNING 'Field record % is missing type property, skipping field', field_record->>'id';
CONTINUE;
END IF;
-- Get the response field object for this field
response_field := response_data->(field_record->>'id');
-- Skip if no response for this field
IF response_field IS NULL THEN
CONTINUE;
END IF;
-- Get field type
field_type := field_record->>'type';
-- Insert new record based on field type
IF field_type = 'multiselect' THEN
-- Handle array values for multiselect
-- Accept both array values and string values
IF response_field->>'value' IS NOT NULL THEN
BEGIN
IF jsonb_typeof(response_field->'value') = 'array' THEN
-- If it's already an array, use it directly
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueStringArray")
VALUES (
response_id,
field_record->>'id',
ARRAY(SELECT jsonb_array_elements_text(response_field->'value'))
);
ELSIF jsonb_typeof(response_field->'value') = 'string' THEN
-- If it's a string, convert it to a single-element array
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueStringArray")
VALUES (
response_id,
field_record->>'id',
ARRAY[response_field->>'value']
);
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert multiselect values for field %', field_record->>'id';
END;
END IF;
ELSIF field_type = 'number' THEN
-- Handle number values
-- Accept both JSON number values and string values that can be converted to numbers
IF response_field->>'value' IS NOT NULL THEN
BEGIN
IF jsonb_typeof(response_field->'value') = 'number' THEN
-- If it's already a JSON number, use it directly
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueNumber")
VALUES (
response_id,
field_record->>'id',
(response_field->>'value')::decimal
);
ELSIF jsonb_typeof(response_field->'value') = 'string' THEN
-- If it's a string, try to convert it to a number
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueNumber")
VALUES (
response_id,
field_record->>'id',
(response_field->>'value')::decimal
);
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert number value for field % (value: %)', field_record->>'id', response_field->>'value';
END;
END IF;
ELSIF field_type = 'select' THEN
-- Handle select values - can be either string or array
IF response_field->>'value' IS NOT NULL THEN
BEGIN
IF jsonb_typeof(response_field->'value') = 'array' THEN
-- If it's an array, take the first element
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
VALUES (
response_id,
field_record->>'id',
(response_field->'value'->>0)
);
ELSIF jsonb_typeof(response_field->'value') = 'string' THEN
-- If it's a string, use it directly
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
VALUES (
response_id,
field_record->>'id',
response_field->>'value'
);
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert select value for field %', field_record->>'id';
END;
END IF;
ELSE
-- Handle all other types as strings
-- Only insert if value is not null and is a valid string
IF response_field->>'value' IS NOT NULL AND jsonb_typeof(response_field->'value') = 'string' THEN
BEGIN
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
VALUES (
response_id,
field_record->>'id',
response_field->>'value'
);
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert string value for field %', field_record->>'id';
END;
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
-- Log the error and continue processing other fields
RAISE WARNING 'Error processing field %', field_record->>'id';
CONTINUE;
END;
END LOOP;
EXCEPTION WHEN OTHERS THEN
-- Log any unhandled errors and continue
RAISE WARNING 'Unhandled error in _process_routing_form_response_fields for response %', response_id;
END;
$$ LANGUAGE plpgsql;
-- Updated trigger function that uses the shared core function
CREATE OR REPLACE FUNCTION handle_routing_form_response_fields()
RETURNS TRIGGER AS $$
DECLARE
response_data jsonb;
form_id text;
BEGIN
-- For INSERT trigger on RoutingFormResponseDenormalized, we need to get the response data from App_RoutingForms_FormResponse
-- For UPDATE trigger on App_RoutingForms_FormResponse, we can use NEW.response directly
IF TG_TABLE_NAME = 'RoutingFormResponseDenormalized' THEN
SELECT response, "formId" INTO response_data, form_id
FROM "App_RoutingForms_FormResponse"
WHERE id = NEW.id;
ELSE
response_data := NEW.response::jsonb;
form_id := NEW."formId";
END IF;
-- Call the shared core function
PERFORM _process_routing_form_response_fields(NEW.id, response_data, form_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- New function to reprocess routing form response fields for a specific response ID
CREATE OR REPLACE FUNCTION reprocess_routing_form_response_fields(response_id integer)
RETURNS void AS $$
DECLARE
response_data jsonb;
form_id text;
BEGIN
-- Get the response data and form ID from App_RoutingForms_FormResponse
SELECT response, "formId" INTO response_data, form_id
FROM "App_RoutingForms_FormResponse"
WHERE id = response_id;
-- Check if the response exists
IF response_data IS NULL THEN
RAISE EXCEPTION 'Response with id % not found', response_id;
END IF;
-- Call the shared core function
PERFORM _process_routing_form_response_fields(response_id, response_data, form_id);
END;
$$ LANGUAGE plpgsql;
+6 -4
View File
@@ -72,10 +72,12 @@ model CalVideoSettings {
eventTypeId Int @id
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
disableRecordingForOrganizer Boolean @default(false)
disableRecordingForGuests Boolean @default(false)
enableAutomaticTranscription Boolean @default(false)
redirectUrlOnExit String?
disableRecordingForOrganizer Boolean @default(false)
disableRecordingForGuests Boolean @default(false)
enableAutomaticTranscription Boolean @default(false)
redirectUrlOnExit String?
disableTranscriptionForGuests Boolean @default(false)
disableTranscriptionForOrganizer Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -30,6 +30,8 @@ const calVideoSettingsSchema = z
disableRecordingForGuests: z.boolean().optional().nullable(),
disableRecordingForOrganizer: z.boolean().optional().nullable(),
enableAutomaticTranscription: z.boolean().optional().nullable(),
disableTranscriptionForGuests: z.boolean().optional().nullable(),
disableTranscriptionForOrganizer: z.boolean().optional().nullable(),
redirectUrlOnExit: z.string().url().optional().nullable(),
})
.optional()
@@ -127,6 +127,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
disableRecordingForOrganizer: true,
disableRecordingForGuests: true,
enableAutomaticTranscription: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
},
},
@@ -13,24 +13,21 @@ type checkInvalidAppCredentialsOptions = {
export const checkInvalidAppCredentials = async ({ ctx }: checkInvalidAppCredentialsOptions) => {
const userId = ctx.user.id;
// First get the teams where user is admin/owner
const userTeamIds = await prisma.membership.findMany({
where: {
userId: userId,
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
select: {
teamId: true,
},
});
const apps = await prisma.credential.findMany({
where: {
OR: [
{
userId: userId,
},
{
team: {
members: {
some: {
userId: userId,
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
},
},
},
],
OR: [{ userId }, { teamId: { in: userTeamIds.map((membership) => membership.teamId) } }],
invalid: true,
},
select: {
@@ -161,7 +161,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }),
};
if (input.banner && input.banner.startsWith("data:image/png;base64,")) {
if (
input.banner &&
(input.banner.startsWith("data:image/png;base64,") ||
input.banner.startsWith("data:image/jpeg;base64,") ||
input.banner.startsWith("data:image/jpg;base64,"))
) {
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
data.bannerUrl = await uploadLogo({
logo: banner,
@@ -172,7 +177,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
data.bannerUrl = null;
}
if (input.logoUrl && input.logoUrl.startsWith("data:image/png;base64,")) {
if (
input.logoUrl &&
(input.logoUrl.startsWith("data:image/png;base64,") ||
input.logoUrl.startsWith("data:image/jpeg;base64,") ||
input.logoUrl.startsWith("data:image/jpg;base64,"))
) {
data.logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logoUrl),
teamId: currentOrgId,
@@ -209,6 +209,12 @@ async function getCroppedImg(
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Context is null, this should never happen.");
// Detect original image format from data URL
const originalFormat =
imageSrc.startsWith("data:image/jpeg") || imageSrc.startsWith("data:image/jpg")
? "image/jpeg"
: "image/png";
canvas.width = width;
canvas.height = height;
@@ -224,5 +230,6 @@ async function getCroppedImg(
canvas.height
);
return canvas.toDataURL("image/png");
// Use original format with quality setting for JPEG
return canvas.toDataURL(originalFormat, originalFormat === "image/jpeg" ? 0.6 : undefined);
}
@@ -192,6 +192,12 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Context is null, this should never happen.");
// Detect original image format from data URL
const originalFormat =
imageSrc.startsWith("data:image/jpeg") || imageSrc.startsWith("data:image/jpg")
? "image/jpeg"
: "image/png";
const maxSize = Math.max(image.naturalWidth, image.naturalHeight);
const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1;
@@ -216,7 +222,7 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
if (resizeRatio <= 0.75) {
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
return getCroppedImg(canvas.toDataURL("image/png"), {
return getCroppedImg(canvas.toDataURL(originalFormat), {
width: canvas.width,
height: canvas.height,
x: 0,
@@ -224,5 +230,6 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
});
}
return canvas.toDataURL("image/png");
// Use original format with quality setting for JPEG
return canvas.toDataURL(originalFormat, originalFormat === "image/jpeg" ? 0.9 : undefined);
}