diff --git a/apps/web/lib/webhooks/constants.ts b/apps/web/lib/webhooks/constants.ts index ee02568bce..ed50eb9f9d 100644 --- a/apps/web/lib/webhooks/constants.ts +++ b/apps/web/lib/webhooks/constants.ts @@ -7,11 +7,12 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = { WebhookTriggerEvents.BOOKING_CANCELLED, WebhookTriggerEvents.BOOKING_CREATED, WebhookTriggerEvents.BOOKING_RESCHEDULED, - ] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"], + WebhookTriggerEvents.MEETING_ENDED, + ] as const, routing_forms: [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"], }; export const WEBHOOK_TRIGGER_EVENTS = [ ...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core, ...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.routing_forms, -] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED", "FORM_SUBMITTED"]; +] as const; diff --git a/apps/web/lib/webhooks/subscriptions.tsx b/apps/web/lib/webhooks/subscriptions.tsx index 67fd8f53db..0145697838 100644 --- a/apps/web/lib/webhooks/subscriptions.tsx +++ b/apps/web/lib/webhooks/subscriptions.tsx @@ -30,6 +30,7 @@ const getWebhooks = async (options: GetSubscriberOptions) => { }, }, select: { + id: true, subscriberUrl: true, payloadTemplate: true, appId: true, diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts index fa8fde0256..f87d17165d 100644 --- a/apps/web/pages/api/book/confirm.ts +++ b/apps/web/pages/api/book/confirm.ts @@ -1,8 +1,19 @@ -import { Booking, BookingStatus, Prisma, SchedulingType, User } from "@prisma/client"; +import { + Booking, + BookingStatus, + Prisma, + SchedulingType, + User, + WebhookTriggerEvents, + Workflow, + WorkflowsOnEventTypes, + WorkflowStep, +} from "@prisma/client"; import type { NextApiRequest } from "next"; import { z } from "zod"; import { refund } from "@calcom/app-store/stripepayment/lib/server"; +import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; import EventManager from "@calcom/core/EventManager"; import { sendDeclinedEmails, sendScheduledEmails } from "@calcom/emails"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; @@ -14,6 +25,7 @@ import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calenda import { getSession } from "@lib/auth"; import { HttpError } from "@lib/core/http/error"; +import getSubscribers from "@lib/webhooks/subscriptions"; import { getTranslation } from "@server/lib/i18n"; @@ -30,6 +42,7 @@ const authorized = async ( id: booking.eventTypeId || undefined, }, select: { + id: true, schedulingType: true, users: true, }, @@ -129,6 +142,7 @@ async function patchHandler(req: NextApiRequest) { recurringEventId: true, status: true, smsReminderNumber: true, + scheduledJobs: true, }, }); @@ -237,6 +251,21 @@ async function patchHandler(req: NextApiRequest) { log.error(error); } } + let updatedBookings: { + scheduledJobs: string[]; + id: number; + startTime: Date; + endTime: Date; + uid: string; + smsReminderNumber: string | null; + eventType: { + workflows: (WorkflowsOnEventTypes & { + workflow: Workflow & { + steps: WorkflowStep[]; + }; + })[]; + } | null; + }[] = []; if (recurringEventId) { // The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related @@ -247,8 +276,9 @@ async function patchHandler(req: NextApiRequest) { status: BookingStatus.PENDING, }, }); - unconfirmedRecurringBookings.map(async (recurringBooking) => { - await prisma.booking.update({ + + const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => { + return prisma.booking.update({ where: { id: recurringBooking.id, }, @@ -258,12 +288,35 @@ async function patchHandler(req: NextApiRequest) { create: scheduleResult.referencesToCreate, }, }, + select: { + eventType: { + select: { + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + }, + }, + uid: true, + startTime: true, + endTime: true, + smsReminderNumber: true, + id: true, + scheduledJobs: true, + }, }); }); + const updatedBookingsResult = await Promise.all(updateBookingsPromise); + updatedBookings = updatedBookings.concat(updatedBookingsResult); } else { // @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed // Should perform update on booking (confirm) -> then trigger the rest handlers - await prisma.booking.update({ + const updatedBooking = await prisma.booking.update({ where: { id: bookingId, }, @@ -273,13 +326,62 @@ async function patchHandler(req: NextApiRequest) { create: scheduleResult.referencesToCreate, }, }, + select: { + eventType: { + select: { + workflows: { + include: { + workflow: { + include: { + steps: true, + }, + }, + }, + }, + }, + }, + uid: true, + startTime: true, + endTime: true, + smsReminderNumber: true, + id: true, + scheduledJobs: true, + }, }); + updatedBookings.push(updatedBooking); } //Workflows - set reminders for confirmed events - if (booking.eventType?.workflows) { - await scheduleWorkflowReminders(booking.eventType.workflows, booking.smsReminderNumber, evt, false); + for (const updatedBooking of updatedBookings) { + if (updatedBooking.eventType?.workflows) { + const evtOfBooking = evt; + evtOfBooking.startTime = updatedBooking.startTime.toISOString(); + evtOfBooking.endTime = updatedBooking.endTime.toISOString(); + evtOfBooking.uid = updatedBooking.uid; + + await scheduleWorkflowReminders( + updatedBooking.eventType.workflows, + updatedBooking.smsReminderNumber, + evtOfBooking, + false + ); + } } + + // schedule job for zapier trigger 'when meeting ends' + const subscriberOptionsMeetingEnded = { + userId: booking.userId || 0, + eventTypeId: booking.eventTypeId || 0, + triggerEvent: WebhookTriggerEvents.MEETING_ENDED, + }; + + const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded); + + subscribersMeetingEnded.forEach((subscriber) => { + updatedBookings.forEach((booking) => { + scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); + }); + }); } else { evt.rejectionReason = rejectionReason; if (recurringEventId) { diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 0e6ce8d29b..63e5a5428f 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -6,6 +6,7 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import { handlePayment } from "@calcom/app-store/stripepayment/lib/server"; +import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; import EventManager from "@calcom/core/EventManager"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import dayjs from "@calcom/dayjs"; @@ -791,13 +792,32 @@ async function handler(req: NextApiRequest) { log.debug(`Booking ${organizerUser.username} completed`); - const eventTrigger: WebhookTriggerEvents = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED"; + const eventTrigger: WebhookTriggerEvents = rescheduleUid + ? WebhookTriggerEvents.BOOKING_RESCHEDULED + : WebhookTriggerEvents.BOOKING_CREATED; const subscriberOptions = { userId: organizerUser.id, eventTypeId, triggerEvent: eventTrigger, }; + const subscriberOptionsMeetingEnded = { + userId: organizerUser.id, + eventTypeId, + triggerEvent: WebhookTriggerEvents.MEETING_ENDED, + }; + + const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded); + + subscribersMeetingEnded.forEach((subscriber) => { + if (rescheduleUid && originalRescheduledBooking) { + cancelScheduledJobs(originalRescheduledBooking); + } + if (booking && booking.status === BookingStatus.ACCEPTED) { + scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); + } + }); + // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED const subscribers = await getSubscribers(subscriberOptions); console.log("evt:", { diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 2a1b0ee1c1..2899a1f6b1 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -1,10 +1,18 @@ -import { BookingStatus, Prisma, PrismaPromise, WebhookTriggerEvents, WorkflowMethods } from "@prisma/client"; +import { + BookingStatus, + Prisma, + PrismaPromise, + WebhookTriggerEvents, + WorkflowMethods, + WorkflowReminder, +} from "@prisma/client"; import { NextApiRequest, NextApiResponse } from "next"; import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { refund } from "@calcom/app-store/stripepayment/lib/server"; +import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; import { deleteMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails } from "@calcom/emails"; @@ -82,6 +90,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { destinationCalendar: true, smsReminderNumber: true, workflowReminders: true, + scheduledJobs: true, }, }); @@ -164,6 +173,21 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ); await Promise.all(promises); + //Workflows - schedule reminders + if (bookingToDelete.eventType?.workflows) { + await sendCancelledReminders( + bookingToDelete.eventType?.workflows, + bookingToDelete.smsReminderNumber, + evt + ); + } + + let updatedBookings: { + uid: string; + workflowReminders: WorkflowReminder[]; + scheduledJobs: string[]; + }[] = []; + // by cancelling first, and blocking whilst doing so; we can ensure a cancel // action always succeeds even if subsequent integrations fail cancellation. if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) { @@ -181,8 +205,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { cancellationReason: cancellationReason, }, }); + const allUpdatedBookings = await prisma.booking.findMany({ + where: { + recurringEventId: bookingToDelete.recurringEventId, + startTime: { + gte: new Date(), + }, + }, + select: { + workflowReminders: true, + uid: true, + scheduledJobs: true, + }, + }); + updatedBookings = updatedBookings.concat(allUpdatedBookings); } else { - await prisma.booking.update({ + const updatedBooking = await prisma.booking.update({ where: { uid, }, @@ -190,7 +228,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { status: BookingStatus.CANCELLED, cancellationReason: cancellationReason, }, + select: { + workflowReminders: true, + uid: true, + scheduledJobs: true, + }, }); + updatedBookings.push(updatedBooking); } /** TODO: Remove this without breaking functionality */ @@ -299,37 +343,35 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - //Workflows - delete all reminders for that booking + // delete scheduled jobs of cancelled bookings + updatedBookings.forEach((booking) => { + cancelScheduledJobs(booking); + }); + + //Workflows - delete all reminders for bookings const remindersToDelete: PrismaPromise[] = []; - bookingToDelete.workflowReminders.forEach((reminder) => { - if (reminder.scheduled && reminder.referenceId) { - if (reminder.method === WorkflowMethods.EMAIL) { - deleteScheduledEmailReminder(reminder.referenceId); - } else if (reminder.method === WorkflowMethods.SMS) { - deleteScheduledSMSReminder(reminder.referenceId); + updatedBookings.forEach((booking) => { + booking.workflowReminders.forEach((reminder) => { + if (reminder.scheduled && reminder.referenceId) { + if (reminder.method === WorkflowMethods.EMAIL) { + deleteScheduledEmailReminder(reminder.referenceId); + } else if (reminder.method === WorkflowMethods.SMS) { + deleteScheduledSMSReminder(reminder.referenceId); + } } - } - const reminderToDelete = prisma.workflowReminder.deleteMany({ - where: { - id: reminder.id, - }, + const reminderToDelete = prisma.workflowReminder.deleteMany({ + where: { + id: reminder.id, + }, + }); + remindersToDelete.push(reminderToDelete); }); - remindersToDelete.push(reminderToDelete); }); await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes].concat(remindersToDelete)); await sendCancelledEmails(evt); - //Workflows - schedule reminders - if (bookingToDelete.eventType?.workflows) { - await sendCancelledReminders( - bookingToDelete.eventType?.workflows, - bookingToDelete.smsReminderNumber, - evt - ); - } - res.status(204).end(); } diff --git a/packages/app-store/zapier/README.md b/packages/app-store/zapier/README.md index 75acbe6c12..8518d3817a 100644 --- a/packages/app-store/zapier/README.md +++ b/packages/app-store/zapier/README.md @@ -33,7 +33,7 @@ If you run it on localhost, check out the [additional information](https://githu ## Triggers -Booking created, Booking rescheduled, Booking cancelled +Booking created, Booking rescheduled, Booking cancelled, Meeting ended ### Booking created @@ -54,7 +54,7 @@ Booking created, Booking rescheduled, Booking cancelled - PerformList: GET ``/api/integrations/zapier/listBookings 3. Test your API request -Create the other two triggers (booking rescheduled, booking cancelled) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created) +Create the other triggers (booking rescheduled, booking cancelled and meeting ended) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created) ### Set ZAPIER_INVITE_LINK diff --git a/packages/app-store/zapier/api/subscriptions/addSubscription.ts b/packages/app-store/zapier/api/subscriptions/addSubscription.ts index dbb535d4d3..9a5f180fe3 100644 --- a/packages/app-store/zapier/api/subscriptions/addSubscription.ts +++ b/packages/app-store/zapier/api/subscriptions/addSubscription.ts @@ -1,10 +1,13 @@ +import { BookingStatus, WebhookTriggerEvents } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { v4 } from "uuid"; +import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler"; import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { const apiKey = req.query.apiKey as string; if (!apiKey) { @@ -19,21 +22,43 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { subscriberUrl, triggerEvent } = req.body; - if (req.method === "POST") { - try { - const createSubscription = await prisma.webhook.create({ - data: { - id: v4(), + try { + const createSubscription = await prisma.webhook.create({ + data: { + id: v4(), + userId: validKey.userId, + eventTriggers: [triggerEvent], + subscriberUrl, + active: true, + appId: "zapier", + }, + }); + + if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) { + //schedule job for already existing bookings + const bookings = await prisma.booking.findMany({ + where: { userId: validKey.userId, - eventTriggers: [triggerEvent], - subscriberUrl, - active: true, - appId: "zapier", + startTime: { + gte: new Date(), + }, + status: BookingStatus.ACCEPTED, }, }); - res.status(200).json(createSubscription); - } catch (error) { - return res.status(500).json({ message: "Could not create subscription." }); + + for (const booking of bookings) { + scheduleTrigger(booking, createSubscription.subscriberUrl, { + id: createSubscription.id, + appId: createSubscription.appId, + }); + } } + res.status(200).json(createSubscription); + } catch (error) { + return res.status(500).json({ message: "Could not create subscription." }); } } + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts index 5d645d3109..f5a01d5dbf 100644 --- a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts +++ b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts @@ -1,10 +1,18 @@ +import { WebhookTriggerEvents } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.query.apiKey as string; +const querySchema = z.object({ + apiKey: z.string(), + id: z.string(), +}); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { apiKey, id } = querySchema.parse(req.query); if (!apiKey) { return res.status(401).json({ message: "No API key provided" }); @@ -16,14 +24,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "API key not valid" }); } - const id = req.query.id as string; + const webhook = await prisma.webhook.findFirst({ + where: { + id, + }, + }); - if (req.method === "DELETE") { - await prisma.webhook.delete({ + if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) { + const bookingsWithScheduledJobs = await prisma.booking.findMany({ where: { - id, + userId: validKey.userId, + scheduledJobs: { + isEmpty: false, + }, }, }); - res.status(204).json({ message: "Subscription is deleted." }); + for (const booking of bookingsWithScheduledJobs) { + const updatedScheduledJobs = booking.scheduledJobs.filter( + (scheduledJob) => scheduledJob !== `zapier_${webhook.id}` + ); + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + scheduledJobs: updatedScheduledJobs, + }, + }); + } } + + await prisma.webhook.delete({ + where: { + id, + }, + }); + res.status(204).json({ message: "Subscription is deleted." }); } + +export default defaultHandler({ + DELETE: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/zapier/api/subscriptions/listBookings.ts b/packages/app-store/zapier/api/subscriptions/listBookings.ts index 375587dde3..76b3c2f332 100644 --- a/packages/app-store/zapier/api/subscriptions/listBookings.ts +++ b/packages/app-store/zapier/api/subscriptions/listBookings.ts @@ -1,9 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { const apiKey = req.query.apiKey as string; if (!apiKey) { @@ -16,30 +17,32 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "API key not valid" }); } - if (req.method === "GET") { - try { - const bookings = await prisma.booking.findMany({ - take: 3, - where: { - userId: validKey.userId, - }, - select: { - ...bookingMinimalSelect, - location: true, - attendees: { - select: { - name: true, - email: true, - timeZone: true, - }, + try { + const bookings = await prisma.booking.findMany({ + take: 3, + where: { + userId: validKey.userId, + }, + select: { + ...bookingMinimalSelect, + location: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, }, }, - }); + }, + }); - res.status(201).json(bookings); - } catch (error) { - console.error(error); - return res.status(500).json({ message: "Unable to get bookings." }); - } + res.status(201).json(bookings); + } catch (error) { + console.error(error); + return res.status(500).json({ message: "Unable to get bookings." }); } } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/zapier/lib/nodeScheduler.ts b/packages/app-store/zapier/lib/nodeScheduler.ts new file mode 100644 index 0000000000..6aa7b0bdab --- /dev/null +++ b/packages/app-store/zapier/lib/nodeScheduler.ts @@ -0,0 +1,83 @@ +import schedule from "node-schedule"; + +import prisma from "@calcom/prisma"; + +export async function scheduleTrigger( + booking: { id: number; endTime: Date; scheduledJobs: string[] }, + subscriberUrl: string, + subscriber: { id: string; appId: string | null } +) { + //schedule job to call subscriber url at the end of meeting + const job = schedule.scheduleJob( + `${subscriber.appId}_${subscriber.id}`, + booking.endTime, + async function () { + const body = JSON.stringify(booking); + await fetch(subscriberUrl, { + method: "POST", + body, + }); + + //remove scheduled job from bookings once triggered + const updatedScheduledJobs = booking.scheduledJobs.filter((scheduledJob) => { + return scheduledJob !== `${subscriber.appId}_${subscriber.id}`; + }); + + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + scheduledJobs: updatedScheduledJobs, + }, + }); + } + ); + + //add scheduled job name to booking + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + scheduledJobs: { + push: job.name, + }, + }, + }); +} + +export async function cancelScheduledJobs( + booking: { uid: string; scheduledJobs?: string[] }, + appId?: string | null +) { + let scheduledJobs = booking.scheduledJobs || []; + + if (booking.scheduledJobs) { + booking.scheduledJobs.forEach(async (scheduledJob) => { + if (appId) { + if (scheduledJob.startsWith(appId)) { + if (schedule.scheduledJobs[scheduledJob]) { + schedule.scheduledJobs[scheduledJob].cancel(); + } + scheduledJobs = scheduledJobs?.filter((job) => scheduledJob !== job) || []; + } + } else { + //if no specific appId given, delete all scheduled jobs of booking + if (schedule.scheduledJobs[scheduledJob]) { + schedule.scheduledJobs[scheduledJob].cancel(); + } + scheduledJobs = []; + } + + await prisma.booking.update({ + where: { + uid: booking.uid, + }, + data: { + scheduledJobs: scheduledJobs, + }, + }); + }); + } +} diff --git a/packages/app-store/zapier/package.json b/packages/app-store/zapier/package.json index 1fcd4ab001..3d0242f21a 100644 --- a/packages/app-store/zapier/package.json +++ b/packages/app-store/zapier/package.json @@ -6,9 +6,11 @@ "main": "./index.ts", "description": "Workflow automation for everyone. Use the Cal.com Zapier app to trigger your workflows when a booking was created, rescheduled or cancled.", "dependencies": { - "@calcom/lib": "*" + "@calcom/lib": "*", + "node-schedule": "2.1.0" }, "devDependencies": { - "@calcom/types": "*" + "@calcom/types": "*", + "@types/node-schedule": "^2.1.0" } } diff --git a/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts b/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts index 7dc80f234c..06f2405a3e 100644 --- a/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts +++ b/packages/features/ee/workflows/lib/reminders/templates/emailReminderTemplate.ts @@ -1,4 +1,3 @@ -import { WorkflowActions } from ".prisma/client"; import dayjs from "@calcom/dayjs"; const emailReminderTemplate = ( diff --git a/packages/lib/webhooks/constants.ts b/packages/lib/webhooks/constants.ts index ee02568bce..ed50eb9f9d 100644 --- a/packages/lib/webhooks/constants.ts +++ b/packages/lib/webhooks/constants.ts @@ -7,11 +7,12 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = { WebhookTriggerEvents.BOOKING_CANCELLED, WebhookTriggerEvents.BOOKING_CREATED, WebhookTriggerEvents.BOOKING_RESCHEDULED, - ] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"], + WebhookTriggerEvents.MEETING_ENDED, + ] as const, routing_forms: [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"], }; export const WEBHOOK_TRIGGER_EVENTS = [ ...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core, ...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.routing_forms, -] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED", "FORM_SUBMITTED"]; +] as const; diff --git a/packages/lib/webhooks/subscriptions.tsx b/packages/lib/webhooks/subscriptions.tsx index 67fd8f53db..0145697838 100644 --- a/packages/lib/webhooks/subscriptions.tsx +++ b/packages/lib/webhooks/subscriptions.tsx @@ -30,6 +30,7 @@ const getWebhooks = async (options: GetSubscriberOptions) => { }, }, select: { + id: true, subscriberUrl: true, payloadTemplate: true, appId: true, diff --git a/packages/prisma/migrations/20220811234822_add_after_meeting_ends_trigger/migration.sql b/packages/prisma/migrations/20220811234822_add_after_meeting_ends_trigger/migration.sql new file mode 100644 index 0000000000..3a283b8dd8 --- /dev/null +++ b/packages/prisma/migrations/20220811234822_add_after_meeting_ends_trigger/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'MEETING_ENDED'; + +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "scheduledJobs" TEXT[]; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index db53c37a2e..98cce2737c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -291,6 +291,7 @@ model Booking { recurringEventId String? smsReminderNumber String? workflowReminders WorkflowReminder[] + scheduledJobs String[] } model Schedule { @@ -388,6 +389,7 @@ enum WebhookTriggerEvents { BOOKING_RESCHEDULED BOOKING_CANCELLED FORM_SUBMITTED + MEETING_ENDED } model Webhook { diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 41f9afa52f..27b6b15fc4 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -6,6 +6,7 @@ import { z } from "zod"; import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router"; import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server"; import getApps, { getLocationOptions } from "@calcom/app-store/utils"; +import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; @@ -1226,6 +1227,32 @@ const loggedInViewerRouter = createProtectedRouter() } } + // if zapier get disconnected, delete zapier apiKey, delete zapier webhooks and cancel all scheduled jobs from zapier + if (credential.app?.slug === "zapier") { + await prisma.apiKey.deleteMany({ + where: { + userId: ctx.user.id, + appId: "zapier", + }, + }); + await prisma.webhook.deleteMany({ + where: { + appId: "zapier", + }, + }); + const bookingsWithScheduledJobs = await prisma.booking.findMany({ + where: { + userId: ctx.user.id, + scheduledJobs: { + isEmpty: false, + }, + }, + }); + for (const booking of bookingsWithScheduledJobs) { + cancelScheduledJobs(booking, credential.appId); + } + } + // Validated that credential is user's above await prisma.credential.delete({ where: { diff --git a/packages/trpc/server/routers/viewer/apiKeys.tsx b/packages/trpc/server/routers/viewer/apiKeys.tsx index e10c9ca80f..3b73fa14b0 100644 --- a/packages/trpc/server/routers/viewer/apiKeys.tsx +++ b/packages/trpc/server/routers/viewer/apiKeys.tsx @@ -113,6 +113,13 @@ export const apiKeysRouter = createProtectedRouter() }), async resolve({ ctx, input }) { const { id } = input; + + const apiKeyToDelete = await ctx.prisma.apiKey.findFirst({ + where: { + id + } + }) + await ctx.prisma.user.update({ where: { id: ctx.user.id, @@ -125,6 +132,16 @@ export const apiKeysRouter = createProtectedRouter() }, }, }); + + //remove all existing zapier webhooks, as we always have only one zapier API key and the running zaps won't work any more if this key is deleted + if(apiKeyToDelete && apiKeyToDelete.appId === 'zapier'){ + await ctx.prisma.webhook.deleteMany({ + where: { + appId: 'zapier' + } + }) + } + return { id, };