Skip to content

Commit

Permalink
feat: automatic no show (#16727)
Browse files Browse the repository at this point in the history
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
  • Loading branch information
6 people authored Oct 10, 2024
1 parent ef88eff commit 395381d
Show file tree
Hide file tree
Showing 43 changed files with 1,103 additions and 69 deletions.
36 changes: 36 additions & 0 deletions apps/web/cron-tester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { CronJob } from "cron";

async function fetchCron(endpoint: string) {
const apiKey = process.env.CRON_API_KEY;

const res = await fetch(`http://localhost:3000/api${endpoint}?${apiKey}`, {
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.CRON_SECRET}`,
},
});
const json = await res.json();
console.log(endpoint, json);
}

try {
console.log("⏳ Running cron endpoints");
new CronJob(
// Each 5 seconds
"*/5 * * * * *",
async function () {
await Promise.allSettled([
fetchCron("/tasks/cron"),
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
//
// fetchCron("/tasks/cleanup"),
]);
},
null,
true,
"America/Los_Angeles"
);
} catch (_err) {
console.error("❌ ❌ ❌ Something went wrong ❌ ❌ ❌");
process.exit(1);
}
12 changes: 7 additions & 5 deletions apps/web/lib/video/[uid]/getServerSideProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { GetServerSidePropsContext } from "next";

import {
generateGuestMeetingTokenFromOwnerMeetingToken,
setEnableRecordingUIForOrganizer,
setEnableRecordingUIAndUserIdForOrganizer,
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
Expand Down Expand Up @@ -81,18 +81,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// set meetingPassword for guests
if (session?.user.id !== bookingObj.user?.id) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
oldVideoReference.meetingPassword
oldVideoReference.meetingPassword,
session?.user.id
);

bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = guestMeetingPassword;
});
}
// Only for backward compatibility for organizer
// Only for backward compatibility and setting user id in particpants for organizer
else {
const meetingPassword = await setEnableRecordingUIForOrganizer(
const meetingPassword = await setEnableRecordingUIAndUserIdForOrganizer(
oldVideoReference.id,
oldVideoReference.meetingPassword
oldVideoReference.meetingPassword,
session?.user.id
);
if (!!meetingPassword) {
bookingObj.references.forEach((bookRef) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ export default function EditOAuthClientWebhooks() {
value: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
label: "recording_transcription_generated",
},
{
value: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
label: "after_hosts_cal_video_no_show",
},
{
value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
label: "after_guests_cal_video_no_show",
},
]}
onSubmit={async (data) => {
try {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "next dev",
"dev:cron": "ts-node cron-tester.ts",
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
"dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000",
Expand Down Expand Up @@ -173,6 +174,7 @@
"@types/uuid": "8.3.1",
"autoprefixer": "^10.4.12",
"copy-webpack-plugin": "^11.0.0",
"cron": "^3.1.7",
"deasync": "^0.1.30",
"detect-port": "^1.3.0",
"env-cmd": "^10.1.0",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,8 @@
"number_provided": "Phone number will be provided",
"before_event_trigger": "before event starts",
"event_cancelled_trigger": "when event is canceled",
"after_hosts_cal_video_no_show": "After hosts don't join cal video",
"after_guests_cal_video_no_show": "After guests don't join cal video",
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendees",
Expand Down Expand Up @@ -1643,6 +1645,9 @@
"email_address_action": "send email to a specific email address",
"after_event_trigger": "after event ends",
"how_long_after": "How long after event ends?",
"how_long_after_hosts_no_show": "How long after hosts don't show up on cal video meeting?",
"how_long_after_guests_no_show": "How long after guests don't show up on cal video meeting?",
"how_long_after_user_no_show_minutes": "How long after the users don't show up on cal video meeting?",
"no_available_slots": "No Available slots",
"time_available": "Time available",
"cant_find_the_right_conferencing_app_visit_our_app_store": "Can't find the right conferencing app? Visit our <1>App Store</1>.",
Expand Down
12 changes: 12 additions & 0 deletions apps/web/test/utils/bookingScenario/bookingScenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ type WhiteListedBookingProps = {
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
credentialId?: number | null;
})[];
user?: { id: number };
bookingSeat?: Prisma.BookingSeatCreateInput[];
createdAt?: string;
};
Expand Down Expand Up @@ -400,6 +401,7 @@ async function addBookingsToDb(
bookings: (Prisma.BookingCreateInput & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
references: any[];
user?: { id: number };
})[]
) {
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
Expand Down Expand Up @@ -490,6 +492,16 @@ export async function addBookings(bookings: InputBooking[]) {
};
}

if (booking?.user?.id) {
bookingCreate.user = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
connect: {
id: booking.user.id,
},
};
}

return bookingCreate;
})
);
Expand Down
31 changes: 12 additions & 19 deletions packages/app-store/dailyvideo/lib/VideoApiAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";

import { handleErrorsJson } from "@calcom/lib/errors";
import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys";
import { fetcher } from "@calcom/lib/dailyApiFetcher";
import { prisma } from "@calcom/prisma";
import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils";
import {
Expand All @@ -15,7 +16,6 @@ import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapt

import { ZSubmitBatchProcessorJobRes, ZGetTranscriptAccessLink } from "../zod";
import type { TSubmitBatchProcessorJobRes, TGetTranscriptAccessLink, batchProcessorBody } from "../zod";
import { getDailyAppKeys } from "./getDailyAppKeys";
import {
dailyReturnTypeSchema,
getTranscripts,
Expand Down Expand Up @@ -54,19 +54,6 @@ export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = {
teamId: null,
};

export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
const { api_key } = await getDailyAppKeys();
return fetch(`https://api.daily.co/v1${endpoint}`, {
method: "GET",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json",
...init?.headers,
},
...init,
}).then(handleErrorsJson);
};

function postToDailyAPI(endpoint: string, body: Record<string, unknown>) {
return fetcher(endpoint, {
method: "POST",
Expand Down Expand Up @@ -111,7 +98,10 @@ async function processTranscriptsInBatches(transcriptIds: Array<string>) {
return allTranscriptsAccessLinks;
}

export const generateGuestMeetingTokenFromOwnerMeetingToken = async (meetingToken: string | null) => {
export const generateGuestMeetingTokenFromOwnerMeetingToken = async (
meetingToken: string | null,
userId?: number
) => {
if (!meetingToken) return null;

const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse);
Expand All @@ -120,28 +110,31 @@ export const generateGuestMeetingTokenFromOwnerMeetingToken = async (meetingToke
room_name: token.room_name,
exp: token.exp,
enable_recording_ui: false,
user_id: userId,
},
}).then(meetingTokenSchema.parse);

return guestMeetingToken.token;
};

// Only for backward compatibility
export const setEnableRecordingUIForOrganizer = async (
export const setEnableRecordingUIAndUserIdForOrganizer = async (
bookingReferenceId: number,
meetingToken: string | null
meetingToken: string | null,
userId?: number
) => {
if (!meetingToken) return null;

const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse);
if (token.enable_recording_ui === false) return null;
if (token.enable_recording_ui === false && !!token.user_id) return null;

const organizerMeetingToken = await postToDailyAPI("/meeting-tokens", {
properties: {
room_name: token.room_name,
exp: token.exp,
enable_recording_ui: false,
is_owner: true,
user_id: userId,
},
}).then(meetingTokenSchema.parse);

Expand Down
9 changes: 6 additions & 3 deletions packages/app-store/dailyvideo/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,17 @@ export const getRooms = z
})
.passthrough();

export const meetingTokenSchema = z.object({
token: z.string(),
});
export const meetingTokenSchema = z
.object({
token: z.string(),
})
.passthrough();

export const ZGetMeetingTokenResponseSchema = z
.object({
room_name: z.string(),
exp: z.number(),
enable_recording_ui: z.boolean().optional(),
user_id: z.number().optional(),
})
.passthrough();
17 changes: 17 additions & 0 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
OrganizerDefaultConferencingAppType,
getLocationValueForDB,
} from "@calcom/app-store/locations";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
Expand Down Expand Up @@ -88,6 +89,7 @@ import { getSeatedBooking } from "./handleNewBooking/getSeatedBooking";
import { getVideoCallDetails } from "./handleNewBooking/getVideoCallDetails";
import { handleAppsStatus } from "./handleNewBooking/handleAppsStatus";
import { loadAndValidateUsers } from "./handleNewBooking/loadAndValidateUsers";
import { scheduleNoShowTriggers } from "./handleNewBooking/scheduleNoShowTriggers";
import type {
Invitee,
IEventTypePaymentCredentialType,
Expand Down Expand Up @@ -1654,6 +1656,21 @@ async function handler(
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}

try {
if (isConfirmedByDefault && (booking.location === DailyLocationType || booking.location?.trim() === "")) {
await scheduleNoShowTriggers({
booking: { startTime: booking.startTime, id: booking.id },
triggerForUser,
organizerUser: { id: organizerUser.id },
eventTypeId,
teamId,
orgId,
});
}
} catch (error) {
loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error }));
}

// booking successful
req.statusCode = 201;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import dayjs from "@calcom/dayjs";
import tasker from "@calcom/features/tasker";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";

type ScheduleNoShowTriggersArgs = {
booking: {
startTime: Date;
id: number;
};
triggerForUser: number | true | null;
organizerUser: { id: number };
eventTypeId: number;
teamId?: number | null;
orgId?: number | null;
};

export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) => {
const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId } = args;
// Add task for automatic no show in cal video
const noShowPromises: Promise<any>[] = [];

const subscribersHostsNoShowStarted = await getWebhooks({
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
teamId,
orgId,
});

noShowPromises.push(
...subscribersHostsNoShowStarted.map((webhook) => {
if (booking?.startTime && webhook.time && webhook.timeUnit) {
const scheduledAt = dayjs(booking.startTime)
.add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType)
.toDate();
return tasker.create(
"triggerHostNoShowWebhook",
{
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: booking.id,
// Prevents null values from being serialized
webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit },
},
{ scheduledAt }
);
}
return Promise.resolve();
})
);

const subscribersGuestsNoShowStarted = await getWebhooks({
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
teamId,
orgId,
});

noShowPromises.push(
...subscribersGuestsNoShowStarted.map((webhook) => {
if (booking?.startTime && webhook.time && webhook.timeUnit) {
const scheduledAt = dayjs(booking.startTime)
.add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType)
.toDate();

return tasker.create(
"triggerGuestNoShowWebhook",
{
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
bookingId: booking.id,
// Prevents null values from being serialized
webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit },
},
{ scheduledAt }
);
}

return Promise.resolve();
})
);

await Promise.all(noShowPromises);

// TODO: Support no show workflows
// const workflowHostsNoShow = workflows.filter(
// (workflow) => workflow.trigger === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
// );
// const workflowGuestsNoShow = workflows.filter(
// (workflow) => workflow.trigger === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
// );
};
Loading

0 comments on commit 395381d

Please sign in to comment.