Skip to content

Commit a7830c8

Browse files
authored
Merge branch 'main' into refactor/circular-deps
2 parents 2bcd0b2 + 60a8841 commit a7830c8

6 files changed

Lines changed: 403 additions & 191 deletions

File tree

apps/api/v2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@axiomhq/winston": "^1.2.0",
3939
"@calcom/platform-constants": "*",
4040
"@calcom/platform-enums": "*",
41-
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.340",
41+
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344",
4242
"@calcom/platform-types": "*",
4343
"@calcom/platform-utils": "*",
4444
"@calcom/prisma": "*",

docs/self-hosting/deployments/railway.mdx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ title: "Railway"
33
icon: "square-r"
44
---
55

6-
You can deploy Cal.com on Railway using the button below.
7-
The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal.com on their platform.
6+
You can deploy Cal.com on Railway using the button below.
87

98
## One Click Deployment
109

11-
<a href="https://railway.app/new/template/cal" target="_blank">
10+
<a href="https://railway.com/new/template/calcom" target="_blank">
1211
<img src="/images/deploy-railway.svg" noZoom alt="Deploy on Railway" />
13-
</a>
12+
</a>

docs/self-hosting/installation.mdx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,39 @@ yarn workspace @calcom/api-v2 build
115115
yarn workspace @calcom/api-v2 start
116116
```
117117

118-
## Vercel
118+
## One Click Deployments
119119

120-
As Cal.com is written in Next.js, Vercel is the perfect platform to host this on (Vercel built Next.js). You can simply follow the [instructions provided by Vercel to get started](https://vercel.com/docs/concepts/get-started). All you do is fork your own version of the repository, click new on Vercel and select the repository. It'll pretty much do the rest for you. The one thing you will need to do is set the environment variables (the .env file). As you can't create a .env file on Vercel, you can go into the settings and manually add each variable. Use the .env.example file for reference as to what you should add. You can learn more about [setting environment variables on Vercel here](https://vercel.com/docs/concepts/projects/environment-variables).
120+
### Azure
121+
122+
<a href="https://portal.azure.com/#create/Microsoft.Template/uri/https://raw.githubusercontent.com/calcom/docker/main/azuredeploy.json" target="_blank">
123+
<img src="https://aka.ms/deploytoazurebutton" noZoom alt="Deploy to Azure"/>
124+
</a>
125+
126+
## Elestio
127+
128+
<a href="https://elest.io/open-source/cal.com" target="_blank">
129+
<img src="/images/deploy-elestio.png" noZoom alt="Deploy on Elestio" />
130+
</a>
131+
132+
### GCP
133+
<a href="https://deploy.cloud.run/?git_repo=https://github.com/calcom/docker" target="_blank">
134+
<img src="https://storage.googleapis.com/gweb-cloudblog-publish/images/run_on_google_cloud.max-300x300.png" noZoom alt="Deploy on Google Cloud" />
135+
</a>
136+
137+
### Railway
138+
<a href="https://railway.com/new/template/calcom" target="_blank">
139+
<img src="/images/deploy-railway.svg" noZoom alt="Deploy on Railway" />
140+
</a>
141+
142+
### Render
143+
<a href="https://render.com/deploy?repo=https://github.com/calcom/docker" target="_blank">
144+
<img src="/images/deploy-render.svg" alt="Deploy on Render" noZoom />
145+
</a>
146+
147+
### Vercel
148+
<a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F" target="_blank">
149+
<img src="https://vercel.com/button" noZoom alt="Deploy with Vercel" />
150+
</a>
121151

122152
## Other environments
123153

@@ -126,5 +156,5 @@ Cal.com effectively is just a Next.js application, so any possible solution you
126156
That's it. Your new self hosted Cal.com instance should now be up and running.
127157

128158
<Card href="/self-hosting/deployments" title="Deployments" icon="rocket">
129-
For more details on specific deployment instructions for AWS, Azure, etc.
159+
For more details on specific deployment instructions for AWS, Azure, Railway, etc.
130160
</Card>
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import { default as cloneDeep } from "lodash/cloneDeep";
2+
import type { Logger } from "tslog";
3+
4+
import dayjs from "@calcom/dayjs";
5+
import {
6+
allowDisablingHostConfirmationEmails,
7+
allowDisablingAttendeeConfirmationEmails,
8+
} from "@calcom/ee/workflows/lib/allowDisablingStandardEmails";
9+
import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types";
10+
import {
11+
sendRoundRobinRescheduledEmailsAndSMS,
12+
sendRoundRobinScheduledEmailsAndSMS,
13+
sendRoundRobinCancelledEmailsAndSMS,
14+
sendRescheduledEmailsAndSMS,
15+
sendScheduledEmailsAndSMS,
16+
sendOrganizerRequestEmail,
17+
sendAttendeeRequestEmailAndSMS,
18+
} from "@calcom/emails";
19+
import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils";
20+
import type { EventNameObjectType } from "@calcom/lib/event";
21+
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
22+
import { safeStringify } from "@calcom/lib/safeStringify";
23+
import { getTranslation } from "@calcom/lib/server/i18n";
24+
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
25+
import type { DestinationCalendar, Prisma, User } from "@calcom/prisma/client";
26+
import type { SchedulingType } from "@calcom/prisma/enums";
27+
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
28+
import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar";
29+
30+
export const BookingActionMap = {
31+
confirmed: "BOOKING_CONFIRMED",
32+
rescheduled: "BOOKING_RESCHEDULED",
33+
requested: "BOOKING_REQUESTED",
34+
} as const;
35+
36+
type EmailAndSmsPayload = {
37+
evt: CalendarEvent;
38+
eventType: {
39+
metadata?: EventTypeMetadata;
40+
schedulingType: SchedulingType | null;
41+
};
42+
};
43+
44+
type RescheduleEmailAndSmsPayload = EmailAndSmsPayload & {
45+
rescheduleReason?: string;
46+
additionalInformation: AdditionalInformation;
47+
additionalNotes: string | null | undefined;
48+
iCalUID: string;
49+
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
50+
destinationCalendar: DestinationCalendar | null;
51+
isFixed?: boolean;
52+
})[];
53+
changedOrganizer?: boolean;
54+
isRescheduledByBooker: boolean;
55+
originalRescheduledBooking: NonNullable<BookingType>;
56+
};
57+
58+
type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & {
59+
workflows: WorkflowType[];
60+
eventNameObject: EventNameObjectType;
61+
additionalInformation: AdditionalInformation;
62+
additionalNotes: string | null | undefined;
63+
customInputs: Prisma.JsonObject | null | undefined;
64+
};
65+
66+
type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
67+
attendees?: Person[];
68+
additionalNotes?: string | null;
69+
};
70+
71+
type RescheduledSideEffectsPayload = {
72+
action: typeof BookingActionMap.rescheduled;
73+
data: RescheduleEmailAndSmsPayload;
74+
};
75+
76+
type ConfirmedSideEffectsPayload = {
77+
action: typeof BookingActionMap.confirmed;
78+
data: ConfirmedEmailAndSmsPayload;
79+
};
80+
81+
type RequestedSideEffectsPayload = {
82+
action: typeof BookingActionMap.requested;
83+
data: RequestedEmailAndSmsPayload;
84+
};
85+
86+
export type EmailsAndSmsSideEffectsPayload =
87+
| RescheduledSideEffectsPayload
88+
| RequestedSideEffectsPayload
89+
| ConfirmedSideEffectsPayload;
90+
91+
export interface IBookingEmailSmsHandler {
92+
logger: Logger<unknown>;
93+
}
94+
95+
export class BookingEmailSmsHandler {
96+
private readonly log: Logger<unknown>;
97+
98+
constructor(dependencies: IBookingEmailSmsHandler) {
99+
this.log = dependencies.logger.getSubLogger({ prefix: ["BookingEmailSmsHandler"] });
100+
}
101+
102+
public async send(payload: EmailsAndSmsSideEffectsPayload) {
103+
const { action, data } = payload;
104+
105+
if (action === BookingActionMap.rescheduled) {
106+
if (data.eventType.schedulingType === "ROUND_ROBIN") return this._handleRoundRobinRescheduled(data);
107+
return this._handleRescheduled(data);
108+
}
109+
110+
if (action === BookingActionMap.confirmed) return this._handleConfirmed(data);
111+
if (action === BookingActionMap.requested) return this._handleRequested(data);
112+
113+
this.log.warn("Unknown email/SMS action requested.", { action });
114+
}
115+
116+
/**
117+
* Handles notifications for a RESCHEDULED booking.
118+
*/
119+
private async _handleRescheduled(data: RescheduleEmailAndSmsPayload) {
120+
const {
121+
evt,
122+
eventType: { metadata },
123+
rescheduleReason,
124+
additionalNotes,
125+
additionalInformation,
126+
} = data;
127+
128+
await sendRescheduledEmailsAndSMS(
129+
{
130+
...evt,
131+
additionalInformation,
132+
additionalNotes,
133+
cancellationReason: `$RCH$${rescheduleReason || ""}`,
134+
},
135+
metadata
136+
);
137+
}
138+
139+
/**
140+
* Handles notifications for a RESCHEDULED RR booking.
141+
*/
142+
private async _handleRoundRobinRescheduled(data: RescheduleEmailAndSmsPayload) {
143+
const {
144+
evt,
145+
eventType: { metadata },
146+
originalRescheduledBooking,
147+
rescheduleReason,
148+
additionalNotes,
149+
changedOrganizer,
150+
additionalInformation,
151+
users,
152+
isRescheduledByBooker,
153+
iCalUID,
154+
} = data;
155+
const copyEvent = cloneDeep(evt);
156+
const copyEventAdditionalInfo = {
157+
...copyEvent,
158+
additionalInformation,
159+
additionalNotes,
160+
cancellationReason: `$RCH$${rescheduleReason || ""}`,
161+
};
162+
const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo);
163+
this.log.debug("Emails: Sending rescheduled emails for booking confirmation");
164+
165+
const originalBookingMemberEmails: Person[] = [];
166+
167+
for (const user of originalRescheduledBooking.attendees) {
168+
const translate = await getTranslation(user.locale ?? "en", "common");
169+
originalBookingMemberEmails.push({
170+
name: user.name,
171+
email: user.email,
172+
timeZone: user.timeZone,
173+
phoneNumber: user.phoneNumber,
174+
language: { translate, locale: user.locale ?? "en" },
175+
});
176+
}
177+
if (originalRescheduledBooking.user) {
178+
const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common");
179+
const originalOrganizer = originalRescheduledBooking.user;
180+
181+
originalBookingMemberEmails.push({
182+
...originalRescheduledBooking.user,
183+
username: originalRescheduledBooking.user.username ?? undefined,
184+
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
185+
name: originalRescheduledBooking.user.name || "",
186+
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
187+
});
188+
189+
if (changedOrganizer) {
190+
cancelledRRHostEvt.title = originalRescheduledBooking.title;
191+
cancelledRRHostEvt.startTime =
192+
dayjs(originalRescheduledBooking?.startTime).utc().format() || copyEventAdditionalInfo.startTime;
193+
cancelledRRHostEvt.endTime =
194+
dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime;
195+
cancelledRRHostEvt.organizer = {
196+
email: originalOrganizer.email,
197+
name: originalOrganizer.name || "",
198+
timeZone: originalOrganizer.timeZone,
199+
language: { translate, locale: originalOrganizer.locale || "en" },
200+
};
201+
}
202+
}
203+
204+
const newBookingMemberEmails: Person[] = [
205+
...(copyEvent.team?.members || []),
206+
copyEvent.organizer,
207+
...copyEvent.attendees,
208+
];
209+
210+
const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) =>
211+
originalMember.email === newMember.email;
212+
213+
const newBookedMembers = newBookingMemberEmails.filter(
214+
(member) => !originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
215+
);
216+
const cancelledMembers = originalBookingMemberEmails.filter(
217+
(member) => !newBookingMemberEmails.some((nm) => matchOriginalMemberWithNewMember(member, nm))
218+
);
219+
const rescheduledMembers = newBookingMemberEmails.filter((member) =>
220+
originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
221+
);
222+
223+
const reassignedTo = users.find(
224+
(user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email)
225+
);
226+
227+
try {
228+
await Promise.all([
229+
sendRoundRobinRescheduledEmailsAndSMS(
230+
{ ...copyEventAdditionalInfo, iCalUID },
231+
rescheduledMembers,
232+
metadata
233+
),
234+
sendRoundRobinScheduledEmailsAndSMS({
235+
calEvent: copyEventAdditionalInfo,
236+
members: newBookedMembers,
237+
eventTypeMetadata: metadata,
238+
}),
239+
sendRoundRobinCancelledEmailsAndSMS(
240+
cancelledRRHostEvt,
241+
cancelledMembers,
242+
metadata,
243+
reassignedTo
244+
? {
245+
name: reassignedTo.name,
246+
email: reassignedTo.email,
247+
...(isRescheduledByBooker && { reason: "Booker Rescheduled" }),
248+
}
249+
: undefined
250+
),
251+
]);
252+
} catch (err) {
253+
this.log.error("Failed to send rescheduled round robin event related emails", err);
254+
}
255+
}
256+
257+
/**
258+
* Handles notifications for a newly CONFIRMED booking.
259+
*/
260+
private async _handleConfirmed(data: ConfirmedEmailAndSmsPayload) {
261+
const {
262+
evt,
263+
eventType: { metadata },
264+
workflows,
265+
eventNameObject,
266+
additionalInformation,
267+
additionalNotes,
268+
customInputs,
269+
} = data;
270+
271+
let isHostConfirmationEmailsDisabled = metadata?.disableStandardEmails?.confirmation?.host || false;
272+
if (isHostConfirmationEmailsDisabled) {
273+
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
274+
}
275+
276+
let isAttendeeConfirmationEmailDisabled =
277+
metadata?.disableStandardEmails?.confirmation?.attendee || false;
278+
if (isAttendeeConfirmationEmailDisabled) {
279+
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
280+
}
281+
282+
try {
283+
await sendScheduledEmailsAndSMS(
284+
{ ...evt, additionalInformation, additionalNotes, customInputs },
285+
eventNameObject,
286+
isHostConfirmationEmailsDisabled,
287+
isAttendeeConfirmationEmailDisabled,
288+
metadata
289+
);
290+
} catch (err) {
291+
this.log.error("Failed to send scheduled event related emails", err);
292+
}
293+
}
294+
295+
/**
296+
* Handles notifications when a booking REQUEST is made (requires confirmation).
297+
*/
298+
private async _handleRequested(data: RequestedEmailAndSmsPayload) {
299+
const {
300+
evt,
301+
eventType: { metadata },
302+
attendees,
303+
additionalNotes,
304+
} = data;
305+
if (!attendees?.length) {
306+
this.log.error("Requested action called without attendee details.");
307+
return;
308+
}
309+
this.log.debug(
310+
"Action: BOOKING_REQUESTED. Sending request emails.",
311+
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
312+
);
313+
314+
const eventWithNotes = { ...evt, additionalNotes };
315+
316+
try {
317+
await Promise.all([
318+
sendOrganizerRequestEmail(eventWithNotes, metadata),
319+
sendAttendeeRequestEmailAndSMS(eventWithNotes, attendees[0], metadata),
320+
]);
321+
} catch (err) {
322+
this.log.error("Failed to send requested event related emails", err);
323+
}
324+
}
325+
}

0 commit comments

Comments
 (0)