Compare commits

...
14 Commits
Author SHA1 Message Date
GitHub Actions 6f4e51b859 chore: release v5.2.7
Post release / release (push) Canceled after 0s
2025-04-26 01:52:58 +00:00
Omar López 4f5d0db792 docs: add JSDoc documentation to FeaturesRepository class and methods (#20980) 2025-04-26 01:44:37 +00:00
Keith Williams 8733bcaf7a perf: Add credentialId index to selectedCalendar (#20979) 2025-04-26 01:40:28 +00:00
Keith Williams 5aaca2b7aa revert: "feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)" (#20978)
This reverts commit d21f7ccbc8.
2025-04-26 00:46:49 +00:00
Alex van Andel eacd05bf3b perf: Improve performance of period limits (#20974)
* perf: Improve performance of period limits

* Type fixes

* Fixed correct check for instanceof -> isDayjs
2025-04-25 21:07:18 +01:00
Hariom Balhara d51e0182b5 fix: dont persist last booking response, temporarily (#20975)
* dont persist last booking response

* remove dead code to fix ts error
2025-04-25 23:07:48 +05:30
Benny Joo 220897ba86 chore: better code - migrate getServerSideProps file directly to RSC for /apps (#20926)
* migrate getServerSideProps file directly to RSC for /apps

* fix
2025-04-25 06:41:21 -07:00
Benny Joo 78c0203a45 chore: leverage error.tsx in app router (#20955) 2025-04-25 13:55:52 +01:00
Syed Ali Shahbaz 720fd90066 fix: Don't delete Cal video room upon requesting reschedule (#20969)
* Update requestReschedule.handler.ts

* Update requestReschedule.handler.ts
2025-04-25 08:30:44 -03:00
Omar López ca8ee749a2 fix: double end-date parsing when saving availability (#20963)
* fix: double end-date parsing when saving availablity

Signed-off-by: Omar López <zomars@me.com>

* fix: cache hits in current and next month

* Update CalendarService.test.ts

---------

Signed-off-by: Omar López <zomars@me.com>
2025-04-24 19:33:55 -03:00
Anik Dhabal Babu ce2d3a4b00 fix: added order by (#20959) 2025-04-24 19:21:21 +00:00
Anik Dhabal Babu 2923684c7c fix: exclude email from replyTo (#20953)
* fix: exclude email from replyTo

* update

* Update scheduleEmailReminders.ts
2025-04-24 19:20:45 +00:00
Alex van Andel 879ae597be fix: Offset is required but not passed everywhere, default to zero. (#20917) 2025-04-24 16:46:19 +01:00
d21f7ccbc8 feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)
* feat: add membership creation date to Organization Member List table (CAL-5406)

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

* feat: add migration for membership creation date (CAL-5406)

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

* feat: make createdAt and updatedAt nullable (CAL-5406)

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

* feat: add updatedAt column to Organization Member List table (CAL-5406)

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

* fix: use type assertion to access createdAt and updatedAt fields (CAL-5406)

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

* fix: display N/A for null date values (CAL-5406)

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

* fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406)

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

* fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406)

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

* feat: add PostgreSQL trigger for membership timestamps (CAL-5406)

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

* fix: use empty string instead of N/A and add translations for column headers (CAL-5406)

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

* add i18n text

* clean up type issue

* feat: add translation keys for column headers (CAL-5406)

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

* disable sort

* remove duplicated i18n texts

* feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406)

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

* support date range filter

* fix date range for end date

* hide columns by default

* revert wrong change

* add missing selects

* fix e2e test

* fix: remove PostgreSQL trigger and let application handle timestamps (CAL-5406)

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

* feat: add application-level timestamp handling for Membership model (CAL-5406)

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

* add more timestamp handling

* refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406)

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

* refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406)

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

* refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406)

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

* fix e2e test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-24 10:48:06 -03:00
57 changed files with 398 additions and 296 deletions
View File
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -1,7 +1,6 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -25,43 +24,40 @@ export const generateMetadata = async () => {
const Page = async ({ searchParams: _searchParams }: PageProps) => {
const searchParams = await _searchParams;
const t = await getTranslate();
try {
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
]);
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
]);
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={
<AvailabilityCTA
toggleGroupOptions={[
{ value: "mine", label: t("my_availability") },
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
]}
/>
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
) : (
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
)}
</ShellMainAppDir>
);
} catch (error) {
notFound();
}
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={
<AvailabilityCTA
toggleGroupOptions={[
{ value: "mine", label: t("my_availability") },
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
]}
/>
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
) : (
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
)}
</ShellMainAppDir>
);
};
export default Page;
@@ -1,10 +1,12 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { AppCategories } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import AppsPage from "~/apps/apps-view";
@@ -18,13 +20,43 @@ export const generateMetadata = async () => {
);
};
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async () => {
const req = buildLegacyRequest(await headers(), await cookies());
const session = await getServerSession({ req });
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
} else {
appStore = await getAppRegistry();
userAdminTeamsIds = [];
}
const ServerPage = async ({ params, searchParams }: PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const categoryQuery = appStore.map(({ categories }) => ({
categories: categories || [],
}));
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
}
return c;
}, {} as Record<string, number>);
return <AppsPage {...props} />;
const props = {
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
count,
}))
.sort(function (a, b) {
return b.count - a.count;
}),
appStore,
userAdminTeams: userAdminTeamsIds,
};
return <AppsPage {...props} isAdmin={session?.user?.role === "ADMIN"} />;
};
export default ServerPage;
@@ -13,7 +13,8 @@ export function AppHomePageSkeleton() {
isPublic
heading={t("app_store")}
subtitle={t("app_store_description")}
headerClassName="sm:hidden lg:block hidden">
headerClassName="sm:hidden lg:block hidden"
isAdmin={false}>
<SkeletonContainer>
<div className="flex flex-col gap-y-8">
{/* Featured Categories section */}
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -1,6 +1,5 @@
import { type Params } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
@@ -36,23 +35,17 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const Page = async ({ params }: { params: Params }) => {
const input = orgIdSchema.safeParse(await params);
if (!input.success) notFound();
if (!input.success) throw new Error("Invalid access");
try {
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const t = await getTranslate();
return (
<SettingsHeader
title={`${t("editing_org")}: ${org.name}`}
description={t("admin_orgs_edit_description")}>
<LicenseRequired>
<OrgForm org={org} />
</LicenseRequired>
</SettingsHeader>
);
} catch {
notFound();
}
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const t = await getTranslate();
return (
<SettingsHeader title={`${t("editing_org")}: ${org.name}`} description={t("admin_orgs_edit_description")}>
<LicenseRequired>
<OrgForm org={org} />
</LicenseRequired>
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -1,6 +1,5 @@
import { type Params } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
@@ -36,24 +35,18 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const Page = async ({ params }: { params: Params }) => {
const input = userIdSchema.safeParse(await params);
if (!input.success) {
notFound();
}
if (!input.success) throw new Error("Invalid access");
try {
const user = await UserRepository.adminFindById(input.data.id);
const t = await getTranslate();
const user = await UserRepository.adminFindById(input.data.id);
const t = await getTranslate();
return (
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
<LicenseRequired>
<UsersEditView user={user} />
</LicenseRequired>
</SettingsHeader>
);
} catch {
notFound();
}
return (
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
<LicenseRequired>
<UsersEditView user={user} />
</LicenseRequired>
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,7 @@
"use client";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
return <Alert severity="error" title="Something went wrong" />;
}
@@ -44,9 +44,7 @@ const Page = async ({ params }: PageProps) => {
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
// const user = session?.user;
const parsed = querySchema.safeParse(await params);
if (!parsed.success) {
notFound();
}
if (!parsed.success) throw new Error("Invalid workflow id");
// const workflow = await WorkflowRepository.getById({ id: +parsed.data.workflow });
// let verifiedEmails, verifiedNumbers;
@@ -1,4 +1,3 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React from "react";
@@ -9,15 +8,14 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
type AppsLayoutProps = {
children: React.ReactNode;
isAdmin: boolean;
actions?: (className?: string) => JSX.Element;
emptyStore?: boolean;
} & Omit<ComponentProps<typeof Shell>, "actions">;
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
export default function AppsLayout({ children, actions, emptyStore, isAdmin, ...rest }: AppsLayoutProps) {
const { t } = useLocale();
const session = useSession();
const router = useRouter();
const isAdmin = session.data?.user.role === "ADMIN";
return (
<Shell {...rest} actions={actions?.("block")}>
-47
View File
@@ -1,47 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { AppCategories } from "@calcom/prisma/enums";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
const session = await getServerSession({ req });
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
} else {
appStore = await getAppRegistry();
userAdminTeamsIds = [];
}
const categoryQuery = appStore.map(({ categories }) => ({
categories: categories || [],
}));
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
}
return c;
}, {} as Record<string, number>);
return {
props: {
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
count,
}))
.sort(function (a, b) {
return b.count - a.count;
}),
appStore,
userAdminTeams: userAdminTeamsIds,
},
};
};
+13 -5
View File
@@ -8,15 +8,14 @@ import { AppStoreCategories } from "@calcom/features/apps/components/Categories"
import { PopularAppsSlider } from "@calcom/features/apps/components/PopularAppsSlider";
import { RecentAppsSlider } from "@calcom/features/apps/components/RecentAppsSlider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { AppCategories } from "@calcom/prisma/enums";
import type { AppFrontendPayload } from "@calcom/types/App";
import classNames from "@calcom/ui/classNames";
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import { type getServerSideProps } from "@lib/apps/getServerSideProps";
import AppsLayout from "@components/apps/layouts/AppsLayout";
const tabs: HorizontalTabItemProps[] = [
@@ -51,15 +50,24 @@ function AppsSearch({
);
}
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export type PageProps = {
categories: {
name: AppCategories;
count: number;
}[];
appStore: AppFrontendPayload[];
userAdminTeams: number[];
isAdmin: boolean;
};
export default function Apps({ categories, appStore, userAdminTeams }: PageProps) {
export default function Apps({ isAdmin, categories, appStore, userAdminTeams }: PageProps) {
const { t } = useLocale();
const [searchText, setSearchText] = useState<string | undefined>(undefined);
return (
<AppsLayout
isPublic
isAdmin={isAdmin}
heading={t("app_store")}
subtitle={t("app_store_description")}
actions={(className) => (
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.6",
"version": "5.2.7",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -7,6 +7,7 @@ import { expect, test, beforeEach, vi, describe } from "vitest";
import "vitest-fetch-mock";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
@@ -187,8 +188,8 @@ test("Calendar Cache is being read on cache HIT", async () => {
await calendarCache.upsertCachedAvailability(
credentialInDb1.id,
{
timeMin: dateFrom1,
timeMax: dateTo1,
timeMin: getTimeMin(dateFrom1),
timeMax: getTimeMax(dateTo1),
items: [{ id: testSelectedCalendar.externalId }],
},
JSON.parse(
@@ -663,7 +663,13 @@ export default class GoogleCalendarService implements Calendar {
): Promise<calendar_v3.Schema$FreeBusyResponse> {
if (shouldServeCache === false) return await this.fetchAvailability(args);
const calendarCache = await CalendarCache.init(null);
const cached = await calendarCache.getCachedAvailability(this.credential.id, args);
const cached = await calendarCache.getCachedAvailability(this.credential.id, {
// Expand the start date to the start of the month to increase cache hits
timeMin: getTimeMin(args.timeMin),
// Expand the end date to the end of the month to increase cache hits
timeMax: getTimeMax(args.timeMax),
items: args.items,
});
if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
return await this.fetchAvailability(args);
}
@@ -1055,9 +1061,9 @@ export default class GoogleCalendarService implements Calendar {
for (const [_eventTypeId, selectedCalendars] of Array.from(selectedCalendarsPerEventType.entries())) {
const parsedArgs = {
/** Expand the start date to the start of the month */
/** Expand the start date to the start of the month to increase cache hits */
timeMin: getTimeMin(),
/** Expand the end date to the end of the month */
/** Expand the end date to the end of the month to increase cache hits */
timeMax: getTimeMax(),
// Dont use eventTypeId in key because it can be used by any eventType
// The only reason we are building it per eventType is because there can be different groups of calendars to lookup the availability for
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
@@ -14,7 +14,7 @@ export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -8,7 +8,7 @@ export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.attendee.language.translate("complete_your_booking_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -15,7 +15,7 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.t("event_cancelled_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -8,7 +8,7 @@ export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.t("event_no_longer_attending_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,7 +1,7 @@
// TODO: We should find a way to keep App specific email templates within the App itself
import type { TFunction } from "i18next";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -26,12 +26,10 @@ export default class AttendeeDailyVideoDownloadRecordingEmail extends BaseEmail
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
getReplyToEmail(this.calEvent),
],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.filter(({ email }) => email !== this.attendee.email).map(({ email }) => email)
),
subject: `${this.t("download_recording_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -37,12 +37,10 @@ export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
getReplyToEmail(this.calEvent),
],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.filter(({ email }) => email !== this.attendee.email).map(({ email }) => email)
),
subject: `${this.t("download_transcript_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -9,7 +9,7 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.t("event_declined_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
@@ -14,7 +14,7 @@ export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.t("location_changed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
@@ -1,5 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -8,16 +8,13 @@ import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeRequestEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = this.calEvent.attendees.map((attendee) => attendee.email);
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
getReplyToEmail(this.calEvent),
],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.filter(({ email }) => email !== this.attendee.email).map(({ email }) => email)
),
subject: `${this.calEvent.attendees[0].language.translate("booking_submitted_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -15,12 +15,10 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
getReplyToEmail(this.calEvent),
],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.filter(({ email }) => email !== this.attendee.email).map(({ email }) => email)
),
subject: `${this.attendee.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -2,7 +2,7 @@ import type { TFunction } from "i18next";
import { default as cloneDeep } from "lodash/cloneDeep";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -40,12 +40,10 @@ export default class AttendeeScheduledEmail extends BaseEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
getReplyToEmail(this.calEvent),
],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.filter(({ email }) => email !== this.attendee.email).map(({ email }) => email)
),
subject: `${this.calEvent.title}`,
html: await this.getHtml(clonedCalEvent, this.attendee),
text: this.getTextBody(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -15,12 +15,10 @@ export default class AttendeeUpdatedEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
getReplyToEmail(this.calEvent),
],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.filter(({ email }) => email !== this.attendee.email).map(({ email }) => email)
),
subject: `${this.attendee.language.translate("event_type_has_been_updated", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -9,7 +9,7 @@ export default class NoShowFeeChargedEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: getReplyToEmail(this.calEvent),
...getReplyToHeader(this.calEvent),
subject: `${this.attendee.language.translate("no_show_fee_charged_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,5 @@
import { APP_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
@@ -16,7 +17,10 @@ export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
}),
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -23,7 +24,10 @@ export default class OrganizerDailyVideoDownloadRecordingEmail extends BaseEmail
return {
to: `${this.calEvent.organizer.email}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.t("download_recording_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -35,7 +36,10 @@ export default class OrganizerDailyVideoDownloadTranscriptEmail extends BaseEmai
return {
to: `${this.calEvent.organizer.email}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.t("download_transcript_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
@@ -16,7 +17,10 @@ export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmai
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.t("location_changed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
@@ -1,5 +1,6 @@
import { checkIfUserHasFeatureController } from "@calcom/features/flags/operations/check-if-user-has-feature.controller";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -21,7 +22,10 @@ export default class OrganizerRequestEmail extends OrganizerScheduledEmail {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.t("awaiting_approval")}: ${this.calEvent.title}`,
html: await this.getHtmlRequestEmail(template, this.calEvent, this.calEvent.organizer),
text: this.getTextBody("event_awaiting_approval"),
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { renderEmail } from "../";
import OrganizerRequestEmail from "./organizer-request-email";
@@ -10,7 +11,10 @@ export default class OrganizerRequestReminderEmail extends OrganizerRequestEmail
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.t("event_awaiting_approval_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,5 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -9,7 +9,6 @@ import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: generateIcsFile({
calEvent: this.calEvent,
@@ -18,7 +17,10 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email), getReplyToEmail(this.calEvent)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.calEvent.organizer.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -3,6 +3,7 @@ import { default as cloneDeep } from "lodash/cloneDeep";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -49,7 +50,10 @@ export default class OrganizerScheduledEmail extends BaseEmail {
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
...getReplyToHeader(
this.calEvent,
this.calEvent.attendees.map(({ email }) => email)
),
subject: `${this.newSeat ? `${this.t("new_attendee")}: ` : ""}${this.calEvent.title}`,
html: await this.getHtml(
clonedCalEvent,
+2 -2
View File
@@ -14,7 +14,7 @@ export type WorkflowEmailData = {
to: string;
subject: string;
html: string;
replyTo: string;
replyTo?: string;
sender?: string | null;
attachments?: Attachment[];
};
@@ -31,7 +31,7 @@ export default class WorkflowEmail extends BaseEmail {
return {
to: this.mailData.to,
from: `${this.mailData.sender || SENDER_NAME} <${this.getMailerOptions().from}>`,
replyTo: this.mailData.replyTo,
...(this.mailData.replyTo && { replyTo: this.mailData.replyTo }),
subject: this.mailData.subject,
html: addHTMLStyles(this.mailData.html),
attachments: this.mailData.attachments,
@@ -11,7 +11,7 @@ vi.mock("@calcom/lib/webstorage", () => ({
},
}));
describe("Last Booking Response", () => {
describe.skip("Last Booking Response", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -1,14 +1,16 @@
import logger from "@calcom/lib/logger";
import { localStorage } from "@calcom/lib/webstorage";
const responsesToStore = ["email", "name"];
// const responsesToStore = ["email", "name"];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const setLastBookingResponse = (responses: Record<string, unknown> | null) => {
if (!responses) return;
const prevResponse = Object.fromEntries(
Object.entries(responses).filter(([key]) => responsesToStore.includes(key))
);
localStorage.setItem("lastBookingResponse", JSON.stringify(prevResponse));
return;
// if (!responses) return;
// const prevResponse = Object.fromEntries(
// Object.entries(responses).filter(([key]) => responsesToStore.includes(key))
// );
// localStorage.setItem("lastBookingResponse", JSON.stringify(prevResponse));
};
export const getLastBookingResponse = () => {
@@ -73,6 +73,9 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
name: true,
bookingSeat: true,
},
orderBy: {
id: "asc",
},
},
user: {
select: {
@@ -7,7 +7,6 @@ import prisma from "@calcom/prisma";
import type { Calendar, SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
import { getTimeMax, getTimeMin } from "./lib/datesForCache";
const log = logger.getSubLogger({ prefix: ["CalendarCacheRepository"] });
@@ -19,8 +18,8 @@ function parseKeyForCache(args: FreeBusyArgs): string {
// Ensure that calendarIds are unique
const uniqueItems = uniqueBy(args.items, ["id"]);
const key = JSON.stringify({
timeMin: getTimeMin(args.timeMin),
timeMax: getTimeMax(args.timeMax),
timeMin: args.timeMin,
timeMax: args.timeMax,
items: uniqueItems,
});
return key;
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getTimeMin, getTimeMax } from "./datesForCache";
describe("getTimeMin", () => {
// Tested on multiple dates
// vi.setSystemTime("2030-04-21T00:00:13Z");
vi.setSystemTime("2025-04-24T00:00:13Z");
it("should return start of current month when no date is passed", () => {
const result = getTimeMin();
const expected = new Date();
@@ -74,4 +75,23 @@ describe("getTimeMax", () => {
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-11-01T00:00:00.000Z"`);
});
it("should handle year changes", () => {
const testDate = "2024-12-24T23:59:59Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2025-02-01T00:00:00.000Z"`);
});
it("should handle next month", () => {
const testDate = "2025-05-01T02:59:59.999Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2025-06-01T00:00:00.000Z"`);
});
it("should handle special case where timeMax is more than 2 months from now but less than 3 months", () => {
vi.setSystemTime("2025-04-24T00:00:13Z");
const testDate = "2025-06-02T23:59:59.999Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2025-06-01T00:00:00.000Z"`);
});
});
@@ -23,44 +23,37 @@ export function getTimeMax(timeMax?: string) {
const currentMonth = now.getUTCMonth();
const currentYear = now.getUTCFullYear();
// If no date is passed, return start of the month two months from *now*.
if (!timeMax) {
// If no date passed, return start of the overnext month from current date
const result = new Date(Date.UTC(currentYear, currentMonth + 2, 1));
result.setUTCHours(0, 0, 0, 0);
return result.toISOString();
}
// If a date is passed, determine the base month/year for calculation.
const date = new Date(timeMax);
const dateMonth = date.getUTCMonth();
const dateYear = date.getUTCFullYear();
// If the date is within current month or next month, return start of overnext month
// Otherwise, return start of overnext month from the date
let targetYear = dateYear;
let targetMonth = dateMonth;
let baseYear = currentYear;
let baseMonth = currentMonth;
// Check if date is within current month or next month
const isWithinCurrentOrNextMonth =
(dateYear === currentYear && dateMonth <= currentMonth + 1) ||
(dateYear === currentYear + 1 && currentMonth === 11 && dateMonth === 0);
// Check if date is within the current month or the next two months relative to *now*.
const isWithinCurrentOrNextTwoMonths =
(dateYear === currentYear && dateMonth <= currentMonth + 2) ||
(dateYear === currentYear + 1 &&
((currentMonth === 10 && dateMonth === 0) || (currentMonth === 11 && dateMonth <= 1)));
if (isWithinCurrentOrNextMonth) {
// For dates within current month or next month, return start of overnext month
targetMonth = dateMonth + 2;
if (targetMonth > 11) {
targetMonth = 0;
targetYear++;
}
} else {
// For dates beyond overnext month, return start of overnext month from the date
targetMonth = dateMonth + 2;
if (targetMonth > 11) {
targetMonth = 0;
targetYear++;
}
// If the input date is beyond the next two months relative to *now*,
// use the *input date's* month/year as the base.
if (!isWithinCurrentOrNextTwoMonths) {
baseYear = dateYear;
baseMonth = dateMonth;
}
// Otherwise, the base remains the current year/month.
const result = new Date(Date.UTC(targetYear, targetMonth, 1));
// Calculate the start of the month two months after the determined base date.
const result = new Date(Date.UTC(baseYear, baseMonth + 2, 1));
result.setUTCHours(0, 0, 0, 0);
return result.toISOString();
}
@@ -332,10 +332,12 @@ export async function handler(req: NextRequest) {
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo:
reminder.booking?.eventType?.customReplyToEmail ??
reminder.booking?.userPrimaryEmail ??
reminder.booking.user?.email,
...(!reminder.booking?.eventType?.hideOrganizerEmail && {
replyTo:
reminder.booking?.eventType?.customReplyToEmail ??
reminder.booking?.userPrimaryEmail ??
reminder.booking.user?.email,
}),
attachments: reminder.workflowStep.includeCalendarEvent
? [
{
@@ -410,10 +412,12 @@ export async function handler(req: NextRequest) {
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo:
reminder.booking?.eventType?.customReplyToEmail ||
reminder.booking?.userPrimaryEmail ||
reminder.booking.user?.email,
...(!reminder.booking?.eventType?.hideOrganizerEmail && {
replyTo:
reminder.booking?.eventType?.customReplyToEmail ||
reminder.booking?.userPrimaryEmail ||
reminder.booking.user?.email,
}),
sender: reminder.workflowStep?.sender,
})
);
@@ -251,7 +251,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
return {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email,
...(!evt.hideOrganizerEmail && { replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email }),
attachments,
sender,
};
+55 -2
View File
@@ -6,7 +6,18 @@ import type { AppFlags } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
import { getFeatureFlag } from "./server/utils";
/**
* Repository class for managing feature flags and feature access control.
* Implements the IFeaturesRepository interface to provide feature flag functionality
* for users, teams, and global application features.
*/
export class FeaturesRepository implements IFeaturesRepository {
/**
* Checks if a feature is enabled globally in the application.
* @param slug - The feature flag identifier to check
* @returns Promise<boolean> - True if the feature is enabled globally, false otherwise
* @throws Error if the feature flag check fails
*/
async checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags) {
try {
return await getFeatureFlag(db, slug);
@@ -15,6 +26,15 @@ export class FeaturesRepository implements IFeaturesRepository {
throw err;
}
}
/**
* Checks if a specific user has access to a feature.
* Checks both direct user feature assignments and team-based feature access.
* @param userId - The ID of the user to check
* @param slug - The feature identifier to check
* @returns Promise<boolean> - True if the user has access to the feature, false otherwise
* @throws Error if the feature access check fails
*/
async checkIfUserHasFeature(userId: number, slug: string) {
try {
/**
@@ -39,6 +59,15 @@ export class FeaturesRepository implements IFeaturesRepository {
throw err;
}
}
/**
* Private helper method to check if a user belongs to any team that has access to a feature.
* @param userId - The ID of the user to check
* @param slug - The feature identifier to check
* @returns Promise<boolean> - True if the user belongs to a team with the feature, false otherwise
* @throws Error if the team feature check fails
* @private
*/
private async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string) {
try {
const user = await db.user.findUnique({
@@ -65,10 +94,34 @@ export class FeaturesRepository implements IFeaturesRepository {
throw err;
}
}
/**
* Checks if a team has access to a specific feature.
* Also checks if the team's parent organization has the feature.
* @param teamId - The ID of the team to check
* @param featureId - The feature identifier to check
* @returns Promise<boolean> - True if the team has access to the feature, false otherwise
* @throws Error if the team feature check fails
*/
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags) {
try {
const teamFeature = await db.teamFeatures.findUnique({
where: { teamId_featureId: { teamId, featureId } },
const teamFeature = await db.teamFeatures.findFirst({
where: {
OR: [
{ teamId, featureId },
{
team: {
parent: {
features: {
some: {
featureId,
},
},
},
},
},
],
},
});
return !!teamFeature;
} catch (err) {
@@ -6,7 +6,7 @@ export const ZSendWorkflowEmailsSchema = z.object({
to: z.array(z.string()),
subject: z.string(),
html: z.string(),
replyTo: z.string(),
replyTo: z.string().optional(),
sender: z.string().nullable().optional(),
attachments: z
.array(
+1 -1
View File
@@ -169,7 +169,7 @@ export const getBusyCalendarTimes = async (
// Subtract 11 hours from the start date to avoid problems in UTC- time zones.
const startDate = dayjs(dateFrom).subtract(11, "hours").format();
// Add 14 hours from the start date to avoid problems in UTC+ time zones.
const endDate = dayjs(dateTo).endOf("month").add(14, "hours").format();
const endDate = dayjs(dateTo).add(14, "hours").format();
if (includeTimeZone) {
results = await getCalendarsEventsWithTimezones(withCredentials, startDate, endDate, selectedCalendars);
+16
View File
@@ -0,0 +1,16 @@
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getReplyToEmail } from "./getReplyToEmail";
export function getReplyToHeader(calEvent: CalendarEvent, additionalEmails?: string | string[]) {
if (calEvent.hideOrganizerEmail) return {};
const replyToEmail = getReplyToEmail(calEvent);
const replyTo = additionalEmails
? Array.isArray(additionalEmails)
? [...additionalEmails, replyToEmail]
: [additionalEmails, replyToEmail]
: replyToEmail;
return { replyTo };
}
+15 -26
View File
@@ -268,51 +268,40 @@ export function isTimeViolatingFutureLimit({
time,
periodLimits,
}: {
time: dayjs.ConfigType;
time: string | Date | number;
periodLimits: PeriodLimits;
}) {
const log = logger.getSubLogger({ prefix: ["isTimeViolatingFutureLimit"] });
// Because we are just going to compare times, we need not convert the time to a particular timezone
// date.isAfter/isBefore are timezone neutral
const dateInSystemTz = dayjs(time);
const dateObj = new Date(time);
if (periodLimits.endOfRollingPeriodEndDayInBookerTz) {
const isAfterRollingEndDay = dateInSystemTz.isAfter(periodLimits.endOfRollingPeriodEndDayInBookerTz);
log.silly("rollingEndDayCheck", {
formattedDate: dateInSystemTz.format(),
isAfterRollingEndDay,
endOfRollingPeriodEndDayInBookerTz: periodLimits.endOfRollingPeriodEndDayInBookerTz.format(),
});
const isAfterRollingEndDay =
dateObj.valueOf() > periodLimits.endOfRollingPeriodEndDayInBookerTz.valueOf();
if (isAfterRollingEndDay)
log.warn(
"Booking is out of bounds due to rolling period end day.",
safeStringify({
formattedDate: dateInSystemTz.format(),
formattedDate: dateObj.toISOString(),
isAfterRollingEndDay,
endOfRollingPeriodEndDayInBookerTz: periodLimits.endOfRollingPeriodEndDayInBookerTz.format(),
endOfRollingPeriodEndDayTs: periodLimits.endOfRollingPeriodEndDayInBookerTz.valueOf(),
})
);
return isAfterRollingEndDay;
}
if (periodLimits.startOfRangeStartDayInEventTz && periodLimits.endOfRangeEndDayInEventTz) {
const isBeforeRangeStart = dateInSystemTz.isBefore(periodLimits.startOfRangeStartDayInEventTz);
const isAfterRangeEnd = dateInSystemTz.isAfter(periodLimits.endOfRangeEndDayInEventTz);
log.silly("rangeCheck", {
formattedDate: dateInSystemTz.format(),
isAfterRangeEnd,
isBeforeRangeStart,
startOfRangeStartDayInEventTz: periodLimits.startOfRangeStartDayInEventTz.format(),
endOfRangeEndDayInEventTz: periodLimits.endOfRangeEndDayInEventTz.format(),
});
const isBeforeRangeStart = dateObj.valueOf() < periodLimits.startOfRangeStartDayInEventTz.valueOf();
const isAfterRangeEnd = dateObj.valueOf() > periodLimits.endOfRangeEndDayInEventTz.valueOf();
if (isBeforeRangeStart || isAfterRangeEnd)
log.warn(
"Booking is out of bounds due to range start and end.",
safeStringify({
formattedDate: dateInSystemTz.format(),
formattedDate: dateObj.toISOString(),
isBeforeRangeStart,
isAfterRangeEnd,
startOfRangeStartDayInEventTz: periodLimits.startOfRangeStartDayInEventTz.format(),
endOfRangeEndDayInEventTz: periodLimits.endOfRangeEndDayInEventTz.format(),
startOfRangeStartDayInEventTz: periodLimits.startOfRangeStartDayInEventTz.toDate().toISOString(),
endOfRangeEndDayInEventTz: periodLimits.endOfRangeEndDayInEventTz.toDate().toISOString(),
})
);
return isBeforeRangeStart || isAfterRangeEnd;
@@ -321,7 +310,7 @@ export function isTimeViolatingFutureLimit({
}
export default function isOutOfBounds(
time: dayjs.ConfigType,
time: NonNullable<dayjs.ConfigType>,
{
periodType,
periodDays,
@@ -354,7 +343,7 @@ export default function isOutOfBounds(
});
const isOutOfBoundsByPeriod = isTimeViolatingFutureLimit({
time,
time: dayjs.isDayjs(time) ? time.toDate() : time,
periodLimits,
});
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "SelectedCalendar_credentialId_idx" ON "SelectedCalendar"("credentialId");
+1
View File
@@ -790,6 +790,7 @@ model SelectedCalendar {
@@index([integration])
@@index([externalId])
@@index([eventTypeId])
@@index([credentialId])
}
enum EventTypeCustomInputType {
+1 -5
View File
@@ -8,10 +8,6 @@ export const bookingMinimalSelect = Prisma.validator<Prisma.BookingSelect>()({
customInputs: true,
startTime: true,
endTime: true,
attendees: {
orderBy: {
id: "asc",
},
},
attendees: true,
metadata: true,
});
@@ -18,7 +18,7 @@ export const ZGetInputSchema = z.object({
beforeCreatedDate: z.string().optional(),
}),
limit: z.number().min(1).max(100),
offset: z.number(),
offset: z.number().default(0),
});
export type TGetInputSchema = z.infer<typeof ZGetInputSchema>;
@@ -251,7 +251,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
})
);
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId);
} else if (bookingRef.type.endsWith("_video")) {
} else if (bookingRef.type.endsWith("_video") && bookingRef.type !== "daily_video") {
return deleteMeeting(
getDelegationCredentialOrRegularCredential({
credentials,