* 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: benny@cal.com <sldisek783@gmail.com> * 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>
259 lines
8.3 KiB
TypeScript
259 lines
8.3 KiB
TypeScript
import dayjs from "@calcom/dayjs";
|
|
import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests";
|
|
import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests";
|
|
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
import type { TimeUnit } from "@calcom/prisma/enums";
|
|
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
|
import { getBooking } from "./getBooking";
|
|
import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName";
|
|
import type { TTriggerNoShowPayloadSchema, TWebhook } from "./schema";
|
|
import { ZSendNoShowWebhookPayloadSchema } from "./schema";
|
|
|
|
type OriginalRescheduledBooking=
|
|
| {
|
|
rescheduledBy?: string | null;
|
|
}
|
|
| null
|
|
| undefined;
|
|
|
|
export type Booking = Awaited<ReturnType<typeof getBooking>>;
|
|
type Webhook = TWebhook;
|
|
export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"];
|
|
type ParticipantsWithEmail = (Participants[number] & { email?: string; isLoggedIn?: boolean })[];
|
|
|
|
export function sendWebhookPayload(
|
|
webhook: Webhook,
|
|
triggerEvent: WebhookTriggerEvents,
|
|
booking: Booking & { guests?: Booking["attendees"] },
|
|
maxStartTime: number,
|
|
participants: ParticipantsWithEmail,
|
|
originalRescheduledBooking?: OriginalRescheduledBooking,
|
|
hostEmail?: string
|
|
): Promise<{ ok: boolean; status: number } | undefined> {
|
|
const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z");
|
|
|
|
return sendGenericWebhookPayload({
|
|
secretKey: webhook.secret,
|
|
triggerEvent,
|
|
createdAt: new Date().toISOString(),
|
|
webhook: {
|
|
subscriberUrl: webhook.subscriberUrl,
|
|
appId: webhook.appId,
|
|
payloadTemplate: webhook.payloadTemplate,
|
|
version: webhook.version,
|
|
},
|
|
data: {
|
|
title: booking.title,
|
|
bookingId: booking.id,
|
|
bookingUid: booking.uid,
|
|
startTime: booking.startTime,
|
|
attendees: booking.attendees,
|
|
endTime: booking.endTime,
|
|
participants,
|
|
...(hostEmail ? { hostEmail } : {}),
|
|
...(triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
|
|
? { noShowHost: booking.noShowHost }
|
|
: {}),
|
|
...(triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW && booking.guests
|
|
? { guests: booking.guests }
|
|
: {}),
|
|
...(originalRescheduledBooking ? { rescheduledBy: originalRescheduledBooking.rescheduledBy } : {}),
|
|
eventType: {
|
|
...booking.eventType,
|
|
id: booking.eventTypeId,
|
|
hosts: undefined,
|
|
users: undefined,
|
|
},
|
|
webhook: {
|
|
...webhook,
|
|
secret: undefined,
|
|
},
|
|
message:
|
|
triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
|
|
? `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}`
|
|
: `Host with email ${hostEmail} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`,
|
|
},
|
|
}).catch((e) => {
|
|
console.error(
|
|
`Error executing webhook for event: ${triggerEvent}, URL: ${webhook.subscriberUrl}`,
|
|
webhook,
|
|
e
|
|
);
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
export function calculateMaxStartTime(startTime: Date, time: number, timeUnit: TimeUnit): number {
|
|
return dayjs(startTime)
|
|
.add(time, timeUnit.toLowerCase() as dayjs.ManipulateType)
|
|
.unix();
|
|
}
|
|
|
|
function checkIfHostJoinedTheCall(email: string, allParticipants: ParticipantsWithEmail): boolean {
|
|
return allParticipants.some(
|
|
(participant) => participant.email && participant.isLoggedIn && participant.email === email
|
|
);
|
|
}
|
|
|
|
function checkIfGuestJoinedTheCall(email: string, allParticipants: ParticipantsWithEmail): boolean {
|
|
return allParticipants.some((participant) => participant.email && participant.email === email);
|
|
}
|
|
|
|
const getUserOrGuestById = async (id: string) => {
|
|
// Try User table (numeric IDs)
|
|
if (!Number.isNaN(Number(id))) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: parseInt(id, 10) },
|
|
select: { email: true },
|
|
});
|
|
if (user) return { email: user.email, isLoggedIn: true };
|
|
}
|
|
|
|
// Try VideoCallGuest table (UUID)
|
|
const guestSession = await prisma.videoCallGuest
|
|
.findUnique({
|
|
where: { id },
|
|
select: { email: true },
|
|
})
|
|
.catch(() => null);
|
|
|
|
return { email: guestSession?.email, isLoggedIn: false };
|
|
};
|
|
|
|
export async function getParticipantsWithEmail(
|
|
allParticipants: Participants
|
|
): Promise<ParticipantsWithEmail> {
|
|
const participantsWithEmail = await Promise.all(
|
|
allParticipants.map(async (participant) => {
|
|
if (!participant.user_id) return participant;
|
|
|
|
const { email, isLoggedIn } = await getUserOrGuestById(participant.user_id);
|
|
return { ...participant, email, isLoggedIn };
|
|
})
|
|
);
|
|
|
|
return participantsWithEmail;
|
|
}
|
|
|
|
export const log = logger.getSubLogger({ prefix: ["triggerNoShowTask"] });
|
|
|
|
export const prepareNoShowTrigger= async (
|
|
payload: string
|
|
): Promise<
|
|
| {
|
|
booking: Booking;
|
|
webhook: TWebhook;
|
|
hosts: Host[];
|
|
hostsThatDidntJoinTheCall: Host[];
|
|
hostsThatJoinedTheCall: Host[];
|
|
numberOfHostsThatJoined: number;
|
|
didGuestJoinTheCall: boolean;
|
|
guestsThatJoinedTheCall: { email: string; name: string }[];
|
|
guestsThatDidntJoinTheCall: { email: string; name: string }[];
|
|
originalRescheduledBooking?: OriginalRescheduledBooking;
|
|
participants: ParticipantsWithEmail;
|
|
}
|
|
| undefined
|
|
> => {
|
|
const { bookingId, webhook } = ZSendNoShowWebhookPayloadSchema.parse(JSON.parse(payload));
|
|
|
|
const booking = await getBooking(bookingId);
|
|
let originalRescheduledBooking = null;
|
|
|
|
if (booking.fromReschedule) {
|
|
originalRescheduledBooking = await prisma.booking.findFirst({
|
|
where: {
|
|
uid: booking.fromReschedule,
|
|
status: {
|
|
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
|
|
},
|
|
},
|
|
select: {
|
|
rescheduledBy: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (booking.status !== BookingStatus.ACCEPTED) {
|
|
log.debug(
|
|
"Booking is not accepted",
|
|
safeStringify({
|
|
bookingId,
|
|
webhook: { id: webhook.id },
|
|
})
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const dailyVideoReference =
|
|
booking.references?.filter((reference) => reference.type === "daily_video")?.pop() ?? null;
|
|
|
|
if (!dailyVideoReference) {
|
|
log.error(
|
|
"Daily video reference not found",
|
|
safeStringify({
|
|
bookingId,
|
|
webhook: { id: webhook.id },
|
|
})
|
|
);
|
|
throw new Error(`Daily video reference not found in triggerHostNoShow with bookingId ${bookingId}`);
|
|
}
|
|
const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid);
|
|
|
|
const { hosts, guests } = getHostsAndGuests(booking);
|
|
const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants);
|
|
|
|
const participantsWithEmail = await getParticipantsWithEmail(allParticipants);
|
|
const hostsThatJoinedTheCall: Host[] = [];
|
|
const hostsThatDidntJoinTheCall: Host[] = [];
|
|
|
|
for (const host of hosts) {
|
|
if (checkIfHostJoinedTheCall(host.email, participantsWithEmail)) {
|
|
hostsThatJoinedTheCall.push(host);
|
|
} else {
|
|
hostsThatDidntJoinTheCall.push(host);
|
|
}
|
|
}
|
|
|
|
const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length;
|
|
|
|
const requireEmailForGuests = booking.eventType?.calVideoSettings?.requireEmailForGuests ?? false;
|
|
|
|
let didGuestJoinTheCall: boolean;
|
|
const guestsThatJoinedTheCall: { email: string; name: string }[] = [];
|
|
const guestsThatDidntJoinTheCall: { email: string; name: string }[] = [];
|
|
|
|
if (requireEmailForGuests) {
|
|
for (const guest of guests) {
|
|
if (checkIfGuestJoinedTheCall(guest.email, participantsWithEmail)) {
|
|
guestsThatJoinedTheCall.push(guest);
|
|
} else {
|
|
guestsThatDidntJoinTheCall.push(guest);
|
|
}
|
|
}
|
|
didGuestJoinTheCall = guestsThatJoinedTheCall.length > 0;
|
|
} else {
|
|
didGuestJoinTheCall = meetingDetails.data.some(
|
|
(meeting) => meeting.max_participants > numberOfHostsThatJoined
|
|
);
|
|
}
|
|
|
|
return {
|
|
hosts,
|
|
hostsThatDidntJoinTheCall,
|
|
hostsThatJoinedTheCall,
|
|
booking,
|
|
numberOfHostsThatJoined,
|
|
webhook,
|
|
didGuestJoinTheCall,
|
|
guestsThatJoinedTheCall,
|
|
guestsThatDidntJoinTheCall,
|
|
originalRescheduledBooking,
|
|
participants: participantsWithEmail,
|
|
};
|
|
};
|