Files
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ab21c7f805 refactor: Cal.diy (#28903)
* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com

This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main:

- Rebrand Cal.com to Cal.diy across the entire codebase
- Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions
- Switch license from AGPL-3.0 to MIT
- Remove docs/ directory (migrated to Nextra at cal.diy)
- Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc.
- Clean up .env.example for self-hosted Cal.diy
- Update Docker image references to calcom/cal.diy
- Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork
- Add PR welcome bot for Cal.diy contributors
- Fix API v2 breaking changes oasdiff ignore entries
- Replace Blacksmith CI runners with default GitHub Actions

3893 files changed, 20789 insertions(+), 411020 deletions(-)

Co-Authored-By: [email protected] <[email protected]>

* refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* rip out org related comments in api v2

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-04-15 09:52:36 -03:00

366 lines
12 KiB
TypeScript

import process from "node:process";
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import {
type EventTypeBrandingData,
getEventTypeService,
} from "@calcom/features/eventtypes/di/EventTypeService.container";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { getTranslation } from "@calcom/i18n/server";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { BookingResponses } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TAddGuestsInputSchema } from "./addGuests.schema";
type ActionSource = string;
export type TUser = Pick<NonNullable<TrpcSessionUser>, "id" | "email" | "organizationId" | "uuid"> &
Partial<Pick<NonNullable<TrpcSessionUser>, "profile">>;
type AddGuestsOptions = {
ctx: {
user: TUser;
};
input: TAddGuestsInputSchema;
emailsEnabled?: boolean;
actionSource: ActionSource;
};
export type Booking = NonNullable<
Awaited<ReturnType<BookingRepository["findByIdIncludeDestinationCalendar"]>>
>;
type OrganizerData = Awaited<ReturnType<typeof getOrganizerData>>;
export const addGuestsHandler = async ({
ctx,
input,
emailsEnabled = true,
actionSource,
}: AddGuestsOptions) => {
const { user } = ctx;
const { bookingId, guests } = input;
const booking = await getBooking(bookingId);
await validateUserPermissions(booking, user);
validateGuestsFieldEnabled(booking);
const organizer = await getOrganizerData(booking.userId);
const uniqueGuests = await sanitizeAndFilterGuests(guests, booking);
const newGuestsDetails = uniqueGuests.map((guest) => ({
name: guest.name || "",
email: guest.email,
timeZone: guest.timeZone || organizer.timeZone,
locale: guest.language || organizer.locale,
}));
const uniqueGuestEmails = uniqueGuests.map((guest) => guest.email);
const bookingAttendees = await updateBookingAttendees(
bookingId,
newGuestsDetails,
uniqueGuestEmails,
booking
);
// Capture new attendee emails after update for audit logging
const _newAttendeeEmails = bookingAttendees.attendees.map((attendee) => attendee.email);
const attendeesList = await prepareAttendeesList(bookingAttendees.attendees);
const evt = await buildCalendarEvent(booking, organizer, attendeesList);
await updateCalendarEvent(booking, evt);
if (emailsEnabled) {
await sendGuestNotifications(evt, booking, uniqueGuestEmails);
}
return { message: "Guests added" };
};
export async function getBooking(bookingId: number) {
const bookingRepository = new BookingRepository(prisma);
const booking = await bookingRepository.findByIdIncludeDestinationCalendar(bookingId);
if (!booking || !booking.user) {
throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
}
return booking;
}
export async function validateUserPermissions(booking: Booking, user: TUser): Promise<void> {
const isOrganizer = booking.userId === user.id;
const isAttendee = !!booking.attendees.find((attendee) => attendee.email === user.email);
if (!isOrganizer && !isAttendee) {
throw new TRPCError({ code: "FORBIDDEN", message: "you_do_not_have_permission" });
}
}
export function validateGuestsFieldEnabled(booking: Booking): void {
const parsedBookingFields = booking?.eventType?.bookingFields
? eventTypeBookingFields.parse(booking.eventType.bookingFields)
: [];
const guestsBookingField = parsedBookingFields.find((field) => field.name === "guests");
if (guestsBookingField?.hidden) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot add guests to this booking. The guests field is disabled for event type "${booking?.eventType?.title}" (ID: ${booking?.eventTypeId}). Please contact the event organizer to enable guest additions.`,
});
}
}
export async function getOrganizerData(userId: number | null) {
if (!userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
}
return await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
timeZone: true,
locale: true,
hideBranding: true,
profiles: {
select: {
organization: { select: { hideBranding: true } },
},
},
},
});
}
function deduplicateGuestEmails(guests: string[]): string[] {
const seenBaseEmails = new Set<string>();
return guests.filter((guest) => {
const baseEmail = extractBaseEmail(guest).toLowerCase();
if (seenBaseEmails.has(baseEmail)) {
return false;
}
seenBaseEmails.add(baseEmail);
return true;
});
}
function getBlacklistedEmails(): string[] {
return process.env.BLACKLISTED_GUEST_EMAILS
? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase())
: [];
}
async function getEmailVerificationRequirements(guestEmails: string[]): Promise<Map<string, boolean>> {
const userRepo = new UserRepository(prisma);
const guestUsers = await userRepo.findManyByEmailsWithEmailVerificationSettings({
emails: guestEmails,
});
const emailToRequiresVerification = new Map<string, boolean>();
for (const user of guestUsers) {
const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase();
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
}
return emailToRequiresVerification;
}
export async function sanitizeAndFilterGuests(
guests: Array<{
email: string;
name?: string;
timeZone?: string;
phoneNumber?: string;
language?: string;
}>,
booking: Booking
): Promise<
Array<{
email: string;
name?: string;
timeZone?: string;
phoneNumber?: string;
language?: string;
}>
> {
const guestEmails = guests.map((guest) => guest.email);
const deduplicatedGuests = deduplicateGuestEmails(guestEmails);
const blacklistedGuestEmails = getBlacklistedEmails();
const guestEmailsLowerCase = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase());
const emailToRequiresVerification = await getEmailVerificationRequirements(guestEmailsLowerCase);
// Create a map of email to guest object for easy lookup
const emailToGuestMap = new Map(
guests.map((guest) => [extractBaseEmail(guest.email).toLowerCase(), guest])
);
const uniqueGuestEmails = deduplicatedGuests.filter((email) => {
const baseGuestEmail = extractBaseEmail(email).toLowerCase();
return (
!booking.attendees.some(
(attendee) => extractBaseEmail(attendee.email).toLowerCase() === baseGuestEmail
) &&
!blacklistedGuestEmails.includes(baseGuestEmail) &&
!emailToRequiresVerification.get(baseGuestEmail)
);
});
if (uniqueGuestEmails.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "emails_must_be_unique_valid" });
}
// Return the full guest objects for unique emails
return uniqueGuestEmails
.map((email) => emailToGuestMap.get(extractBaseEmail(email).toLowerCase()))
.filter((guest): guest is NonNullable<typeof guest> => guest !== undefined);
}
export async function updateBookingAttendees(
bookingId: number,
newAttendees: { name: string; email: string; timeZone: string; locale: string | null }[],
uniqueGuestEmails: string[],
booking: Booking
) {
const bookingResponses = booking.responses as BookingResponses;
const bookingRepository = new BookingRepository(prisma);
return await bookingRepository.updateBookingAttendees({
bookingId,
newAttendees,
updatedResponses: {
...bookingResponses,
guests: [...(bookingResponses?.guests || []), ...uniqueGuestEmails],
},
});
}
export async function prepareAttendeesList(attendees: Booking["attendees"]) {
const attendeesListPromises = attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
return await Promise.all(attendeesListPromises);
}
export async function buildCalendarEvent(
booking: Booking,
organizer: OrganizerData,
attendeesList: Awaited<ReturnType<typeof prepareAttendeesList>>
): Promise<CalendarEvent> {
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
const evt: CalendarEvent = {
title: booking.title || "",
type: (booking.eventType?.title as string) || booking?.title || "",
description: booking.description || "",
startTime: booking.startTime ? dayjs(booking.startTime).format() : "",
endTime: booking.endTime ? dayjs(booking.endTime).format() : "",
organizer: {
email: booking?.userPrimaryEmail ?? organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
attendees: attendeesList,
uid: booking.uid,
iCalUID: booking.iCalUID,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location: booking.location,
destinationCalendar: booking?.destinationCalendar
? [booking?.destinationCalendar]
: booking?.user?.destinationCalendar
? [booking?.user?.destinationCalendar]
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
customReplyToEmail: booking.eventType?.customReplyToEmail,
organizationId: booking.user?.profiles?.[0]?.organizationId ?? null,
hideBranding: booking.eventTypeId
? await getEventTypeService().shouldHideBrandingForEventType(booking.eventTypeId, {
team: booking.eventType?.team
? { hideBranding: booking.eventType.team.hideBranding, parent: booking.eventType.team.parent }
: null,
owner: {
id: organizer.id,
hideBranding: organizer.hideBranding,
profiles: organizer.profiles ?? [],
},
} satisfies EventTypeBrandingData)
: false,
};
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
return evt;
}
export async function updateCalendarEvent(booking: Booking, evt: CalendarEvent): Promise<void> {
if (!booking.user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Booking user not found" });
}
const credentials = await getUsersCredentialsIncludeServiceAccountKey(booking.user);
const eventManager = new EventManager({
...booking.user,
credentials: [...credentials],
});
await eventManager.updateCalendarAttendees(evt, booking);
}
async function sendGuestNotifications(
evt: CalendarEvent,
booking: Booking,
uniqueGuests: string[]
): Promise<void> {
const emailsAndSmsHandler = new BookingEmailSmsHandler({
logger: logger,
});
await emailsAndSmsHandler.handleAddGuests({
evt,
eventType: {
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata),
schedulingType: booking.eventType?.schedulingType || null,
},
newGuests: uniqueGuests,
});
}