diff --git a/apps/web/public/app-store/other.svg b/apps/web/public/app-store/other.svg
new file mode 100644
index 00000000..94876ffa
--- /dev/null
+++ b/apps/web/public/app-store/other.svg
@@ -0,0 +1,73 @@
+
diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx
index 2eaa0fc8..20b47b60 100644
--- a/apps/web/server/routers/viewer.tsx
+++ b/apps/web/server/routers/viewer.tsx
@@ -575,7 +575,7 @@ const loggedInViewerRouter = createProtectedRouter()
const conferencing = apps.flatMap((item) => (item.variant === "conferencing" ? [item] : []));
const payment = apps.flatMap((item) => (item.variant === "payment" ? [item] : []));
const calendar = apps.flatMap((item) => (item.variant === "calendar" ? [item] : []));
-
+ const other = apps.flatMap((item) => (item.variant === "other" ? [item] : []));
return {
conferencing: {
items: conferencing,
@@ -589,6 +589,10 @@ const loggedInViewerRouter = createProtectedRouter()
items: payment,
numActive: countActive(payment),
},
+ other: {
+ items: other,
+ numActive: countActive(other),
+ },
};
},
})
diff --git a/packages/app-store/_example/api/example.ts b/packages/app-store/_example/api/example.ts
index f0988db1..38dc4766 100644
--- a/packages/app-store/_example/api/example.ts
+++ b/packages/app-store/_example/api/example.ts
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
/**
- * This is an example endoint for an app, these will run under `/api/integrations/[...args]`
+ * This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
* @param req
* @param res
*/
diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx
index 05b6453d..4e6428f5 100644
--- a/packages/app-store/components.tsx
+++ b/packages/app-store/components.tsx
@@ -19,6 +19,7 @@ export const InstallAppButtonMap = {
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
+ wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")),
};
export const InstallAppButton = (
diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts
index 4737c63a..6bf6b9a5 100644
--- a/packages/app-store/index.ts
+++ b/packages/app-store/index.ts
@@ -11,6 +11,7 @@ import * as office365video from "./office365video";
import * as slackmessaging from "./slackmessaging";
import * as stripepayment from "./stripepayment";
import * as tandemvideo from "./tandemvideo";
+import * as wipemycalother from "./wipemycalother";
import * as zoomvideo from "./zoomvideo";
const appStore = {
@@ -28,6 +29,7 @@ const appStore = {
stripepayment,
tandemvideo,
zoomvideo,
+ wipemycalother,
};
export default appStore;
diff --git a/packages/app-store/metadata.ts b/packages/app-store/metadata.ts
index 7179eccf..cf09ca45 100644
--- a/packages/app-store/metadata.ts
+++ b/packages/app-store/metadata.ts
@@ -10,6 +10,7 @@ import { metadata as office365video } from "./office365video/_metadata";
import { metadata as slackmessaging } from "./slackmessaging/_metadata";
import { metadata as stripepayment } from "./stripepayment/_metadata";
import { metadata as tandemvideo } from "./tandemvideo/_metadata";
+import { metadata as wipemycalother } from "./wipemycalother/_metadata";
import { metadata as zoomvideo } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
@@ -26,6 +27,7 @@ export const appStoreMetadata = {
stripepayment,
tandemvideo,
zoomvideo,
+ wipemycalother,
};
export default appStoreMetadata;
diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts
index c7c8b298..f5bb6482 100644
--- a/packages/app-store/utils.ts
+++ b/packages/app-store/utils.ts
@@ -50,7 +50,7 @@ export function getLocationOptions(integrations: AppMeta, t: TFunction) {
}
/**
- * This should get all avaialable apps to the user based on his saved
+ * This should get all available apps to the user based on his saved
* credentials, this should also get globally available apps.
*/
function getApps(userCredentials: CredentialData[]) {
diff --git a/packages/app-store/wipemycalother/_metadata.ts b/packages/app-store/wipemycalother/_metadata.ts
new file mode 100644
index 00000000..7371fde5
--- /dev/null
+++ b/packages/app-store/wipemycalother/_metadata.ts
@@ -0,0 +1,26 @@
+import type { App } from "@calcom/types/App";
+
+import _package from "./package.json";
+
+export const metadata = {
+ name: _package.name,
+ description: _package.description,
+ installed: true,
+ category: "other",
+ // If using static next public folder, can then be referenced from the base URL (/).
+ imageSrc: "/api/app-store/_example/icon.svg",
+ logo: "/api/app-store/_example/icon.svg",
+ publisher: "Cal.com",
+ rating: 0,
+ reviews: 0,
+ slug: "wipe-my-cal",
+ title: "Wipe my cal",
+ trending: true,
+ type: "wipemycal_other",
+ url: "https://cal.com/apps/wipe-my-cal",
+ variant: "other",
+ verified: true,
+ email: "help@cal.com",
+} as App;
+
+export default metadata;
diff --git a/packages/app-store/wipemycalother/api/add.ts b/packages/app-store/wipemycalother/api/add.ts
new file mode 100644
index 00000000..d380fc70
--- /dev/null
+++ b/packages/app-store/wipemycalother/api/add.ts
@@ -0,0 +1,42 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import prisma from "@calcom/prisma";
+
+/**
+ * This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
+ * @param req
+ * @param res
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (!req.session?.user?.id) {
+ return res.status(401).json({ message: "You must be logged in to do this" });
+ }
+ const appType = "wipemycal_other";
+ try {
+ const alreadyInstalled = await prisma.credential.findFirst({
+ where: {
+ type: appType,
+ userId: req.session.user.id,
+ },
+ });
+ if (alreadyInstalled) {
+ throw new Error("Already installed");
+ }
+ const installation = await prisma.credential.create({
+ data: {
+ type: appType,
+ key: {},
+ userId: req.session.user.id,
+ },
+ });
+ if (!installation) {
+ throw new Error("Unable to create user credential for wipe-my-cal");
+ }
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ return res.status(500).json({ message: error.message });
+ }
+ return res.status(500);
+ }
+ return res.redirect("/apps/installed");
+}
diff --git a/packages/app-store/wipemycalother/api/index.ts b/packages/app-store/wipemycalother/api/index.ts
new file mode 100644
index 00000000..be507543
--- /dev/null
+++ b/packages/app-store/wipemycalother/api/index.ts
@@ -0,0 +1,2 @@
+export { default as add } from "./add";
+export { default as wipe } from "./wipe";
diff --git a/packages/app-store/wipemycalother/api/wipe.ts b/packages/app-store/wipemycalother/api/wipe.ts
new file mode 100644
index 00000000..39266ec4
--- /dev/null
+++ b/packages/app-store/wipemycalother/api/wipe.ts
@@ -0,0 +1,88 @@
+import { BookingStatus } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+import queue from "queue";
+import { z, ZodError } from "zod";
+
+import prisma from "@calcom/prisma";
+
+import { Reschedule } from "../lib";
+
+const wipeMyCalendarBodySchema = z.object({
+ initialDate: z.string(),
+ endDate: z.string(),
+});
+
+/**
+ * /api/integrations/wipemycalother/wipe
+ * @param req
+ * @param res
+ */
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (!req.session?.user?.id) {
+ return res.status(401).json({ message: "You must be logged in to do this" });
+ }
+ let result = false;
+ try {
+ console.log("try");
+ const { initialDate, endDate } = req.body;
+
+ const todayBookings = await prisma.booking.findMany({
+ where: {
+ startTime: {
+ gte: initialDate,
+ },
+ endTime: {
+ lte: endDate,
+ },
+ status: {
+ in: [BookingStatus.ACCEPTED, BookingStatus.PENDING],
+ },
+ },
+ select: {
+ id: true,
+ uid: true,
+ status: true,
+ },
+ });
+ // const [booking] = todayBookings;
+ const q = queue({ results: [] });
+ if (todayBookings.length > 0) {
+ todayBookings.forEach((booking) =>
+ q.push(() => {
+ return Reschedule(booking.uid, "Can't do it");
+ })
+ );
+ }
+ const result = await q.start();
+ console.log({ result });
+ // result = !!(await Reschedule(booking.uid, "Can't do it"));
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ return res.status(500).json({ message: error.message });
+ }
+ return res.status(500);
+ }
+ return res.status(200).json({ success: result });
+};
+
+function validate(
+ handler: (req: NextApiRequest, res: NextApiResponse) => Promise
+) {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === "POST") {
+ try {
+ wipeMyCalendarBodySchema.parse(req.body);
+ } catch (error) {
+ if (error instanceof ZodError && error?.name === "ZodError") {
+ return res.status(400).json(error?.issues);
+ }
+ return res.status(402);
+ }
+ } else {
+ return res.status(405);
+ }
+ await handler(req, res);
+ };
+}
+
+export default validate(handler);
diff --git a/packages/app-store/wipemycalother/components/InstallAppButton.tsx b/packages/app-store/wipemycalother/components/InstallAppButton.tsx
new file mode 100644
index 00000000..2c755ed4
--- /dev/null
+++ b/packages/app-store/wipemycalother/components/InstallAppButton.tsx
@@ -0,0 +1,18 @@
+import type { InstallAppButtonProps } from "@calcom/app-store/types";
+
+import useAddAppMutation from "../../_utils/useAddAppMutation";
+
+export default function InstallAppButton(props: InstallAppButtonProps) {
+ const mutation = useAddAppMutation("wipemycal_other");
+
+ return (
+ <>
+ {props.render({
+ onClick() {
+ mutation.mutate("");
+ },
+ loading: mutation.isLoading,
+ })}
+ >
+ );
+}
diff --git a/packages/app-store/wipemycalother/components/confirmDialog.tsx b/packages/app-store/wipemycalother/components/confirmDialog.tsx
new file mode 100644
index 00000000..c44fc070
--- /dev/null
+++ b/packages/app-store/wipemycalother/components/confirmDialog.tsx
@@ -0,0 +1,129 @@
+import { ClockIcon, XIcon } from "@heroicons/react/outline";
+import dayjs from "dayjs";
+import { Dispatch, SetStateAction } from "react";
+import { useState } from "react";
+import { useMutation } from "react-query";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import logger from "@calcom/lib/logger";
+import showToast from "@calcom/lib/notification";
+import Button from "@calcom/ui/Button";
+import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
+
+interface IConfirmDialogWipe {
+ isOpenDialog: boolean;
+ setIsOpenDialog: Dispatch>;
+ trpc: any;
+}
+
+interface IWipeMyCalAction {
+ initialDate: string;
+ endDate: string;
+}
+
+const wipeMyCalAction = async (props: IWipeMyCalAction) => {
+ const { initialDate, endDate } = props;
+ const body = {
+ initialDate,
+ endDate,
+ };
+ try {
+ const endpoint = "/api/integrations/wipemycalother/wipe";
+ return fetch(`${process.env.NEXT_PUBLIC_APP_BASE_URL}` + endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ showToast("Error ocurred while trying to cancel bookings", "error");
+ }
+ }
+};
+
+export const ConfirmDialog = (props: IConfirmDialogWipe) => {
+ const { t } = useLocale();
+ const { isOpenDialog, setIsOpenDialog, trpc } = props;
+ const [isLoading, setIsLoading] = useState(false);
+ const today = dayjs();
+ const initialDate = today.startOf("day");
+ const endDate = today.endOf("day");
+ const dateFormat = "ddd, MMM D, YYYY h:mm A";
+ console.log({ props });
+ const utils = trpc.useContext();
+
+ const rescheduleApi = useMutation(
+ async () => {
+ setIsLoading(true);
+ try {
+ const result = await wipeMyCalAction({
+ initialDate: initialDate.toISOString(),
+ endDate: endDate.toISOString(),
+ });
+ if (result) {
+ showToast(t("reschedule_request_sent"), "success");
+ setIsOpenDialog(false);
+ }
+ } catch (error) {
+ showToast(t("unexpected_error_try_again"), "error");
+ // @TODO: notify sentry
+ }
+ setIsLoading(false);
+ },
+ {
+ async onSettled() {
+ await utils.invalidateQueries(["viewer.bookings"]);
+ },
+ }
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/app-store/wipemycalother/components/index.ts b/packages/app-store/wipemycalother/components/index.ts
new file mode 100644
index 00000000..1aa02304
--- /dev/null
+++ b/packages/app-store/wipemycalother/components/index.ts
@@ -0,0 +1,2 @@
+export { default as InstallAppButton } from "./InstallAppButton";
+export { WipeMyCalActionButton } from "./wipeMyCalActionButton";
diff --git a/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx b/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx
new file mode 100644
index 00000000..ccaa2d73
--- /dev/null
+++ b/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx
@@ -0,0 +1,32 @@
+import { useState } from "react";
+
+import Button from "@calcom/ui/Button";
+
+import { ConfirmDialog } from "./confirmDialog";
+
+interface IWipeMyCalActionButtonProps {
+ trpc: any;
+}
+
+const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
+ const { trpc } = props;
+
+ const [openDialog, setOpenDialog] = useState(false);
+ const { isSuccess, isLoading, data } = trpc.useQuery(["viewer.integrations"]);
+
+ return (
+
+ {data &&
+ isSuccess &&
+ !isLoading &&
+ data?.other?.items.find((item: { type: string }) => item.type === "wipemycal_other") && (
+ <>
+
+
+ >
+ )}
+
+ );
+};
+
+export { WipeMyCalActionButton };
diff --git a/packages/app-store/wipemycalother/index.ts b/packages/app-store/wipemycalother/index.ts
new file mode 100644
index 00000000..db3c2b10
--- /dev/null
+++ b/packages/app-store/wipemycalother/index.ts
@@ -0,0 +1,4 @@
+export * as api from "./api";
+export * as components from "./components";
+export * as lib from "./lib";
+export { metadata } from "./_metadata";
diff --git a/packages/app-store/wipemycalother/lib/calendarManager.ts b/packages/app-store/wipemycalother/lib/calendarManager.ts
new file mode 100644
index 00000000..c8964ff6
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/calendarManager.ts
@@ -0,0 +1,77 @@
+import { Credential } from "@prisma/client";
+import _ from "lodash";
+
+import { getUid } from "@calcom/lib/CalEventParser";
+import logger from "@calcom/lib/logger";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+import type { EventResult } from "@calcom/types/EventManager";
+
+import { getCalendar } from "../../_utils/getCalendar";
+
+const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
+
+/** TODO: Remove once all references are updated to app-store */
+export { getCalendar };
+
+export const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => {
+ const uid: string = getUid(calEvent);
+ const calendar = getCalendar(credential);
+ let success = true;
+
+ // Check if the disabledNotes flag is set to true
+ if (calEvent.hideCalendarNotes) {
+ calEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
+ }
+
+ const creationResult = calendar
+ ? await calendar.createEvent(calEvent).catch((e) => {
+ log.error("createEvent failed", e, calEvent);
+ success = false;
+ return undefined;
+ })
+ : undefined;
+
+ return {
+ type: credential.type,
+ success,
+ uid,
+ createdEvent: creationResult,
+ originalEvent: calEvent,
+ };
+};
+
+export const updateEvent = async (
+ credential: Credential,
+ calEvent: CalendarEvent,
+ bookingRefUid: string | null
+): Promise => {
+ const uid = getUid(calEvent);
+ const calendar = getCalendar(credential);
+ let success = true;
+
+ const updatedResult =
+ calendar && bookingRefUid
+ ? await calendar.updateEvent(bookingRefUid, calEvent).catch((e) => {
+ log.error("updateEvent failed", e, calEvent);
+ success = false;
+ return undefined;
+ })
+ : undefined;
+
+ return {
+ type: credential.type,
+ success,
+ uid,
+ updatedEvent: updatedResult,
+ originalEvent: calEvent,
+ };
+};
+
+export const deleteEvent = (credential: Credential, uid: string, event: CalendarEvent): Promise => {
+ const calendar = getCalendar(credential);
+ if (calendar) {
+ return calendar.deleteEvent(uid, event);
+ }
+
+ return Promise.resolve({});
+};
diff --git a/packages/app-store/wipemycalother/lib/emailManager.ts b/packages/app-store/wipemycalother/lib/emailManager.ts
new file mode 100644
index 00000000..abf2f520
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/emailManager.ts
@@ -0,0 +1,35 @@
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import AttendeeRequestRescheduledEmail from "./templates/attendee-request-reschedule-email";
+import OrganizerRequestRescheduledEmail from "./templates/organizer-request-reschedule-email";
+
+export const sendRequestRescheduleEmail = async (
+ calEvent: CalendarEvent,
+ metadata: { rescheduleLink: string }
+) => {
+ const emailsToSend: Promise[] = [];
+
+ emailsToSend.push(
+ new Promise((resolve, reject) => {
+ try {
+ const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
+ resolve(requestRescheduleEmail.sendEmail());
+ } catch (e) {
+ reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
+ }
+ })
+ );
+
+ emailsToSend.push(
+ new Promise((resolve, reject) => {
+ try {
+ const requestRescheduleEmail = new OrganizerRequestRescheduledEmail(calEvent, metadata);
+ resolve(requestRescheduleEmail.sendEmail());
+ } catch (e) {
+ reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
+ }
+ })
+ );
+
+ await Promise.all(emailsToSend);
+};
diff --git a/packages/app-store/wipemycalother/lib/emailServerConfig.ts b/packages/app-store/wipemycalother/lib/emailServerConfig.ts
new file mode 100644
index 00000000..a51d0c7e
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/emailServerConfig.ts
@@ -0,0 +1,34 @@
+import SendmailTransport from "nodemailer/lib/sendmail-transport";
+import SMTPConnection from "nodemailer/lib/smtp-connection";
+
+function detectTransport(): SendmailTransport.Options | SMTPConnection.Options | string {
+ if (process.env.EMAIL_SERVER) {
+ return process.env.EMAIL_SERVER;
+ }
+
+ if (process.env.EMAIL_SERVER_HOST) {
+ const port = parseInt(process.env.EMAIL_SERVER_PORT!);
+ const transport = {
+ host: process.env.EMAIL_SERVER_HOST,
+ port,
+ auth: {
+ user: process.env.EMAIL_SERVER_USER,
+ pass: process.env.EMAIL_SERVER_PASSWORD,
+ },
+ secure: port === 465,
+ };
+
+ return transport;
+ }
+
+ return {
+ sendmail: true,
+ newline: "unix",
+ path: "/usr/sbin/sendmail",
+ };
+}
+
+export const serverConfig = {
+ transport: detectTransport(),
+ from: process.env.EMAIL_FROM,
+};
diff --git a/packages/app-store/wipemycalother/lib/eventManager.ts b/packages/app-store/wipemycalother/lib/eventManager.ts
new file mode 100644
index 00000000..224a02d1
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/eventManager.ts
@@ -0,0 +1,382 @@
+import { Credential, DestinationCalendar } from "@prisma/client";
+import async from "async";
+import merge from "lodash/merge";
+import { v5 as uuidv5 } from "uuid";
+
+import prisma from "@calcom/prisma";
+import type { AdditionInformation, CalendarEvent } from "@calcom/types/Calendar";
+import type {
+ CreateUpdateResult,
+ EventResult,
+ PartialBooking,
+ PartialReference,
+} from "@calcom/types/EventManager";
+import type { VideoCallData } from "@calcom/types/VideoApiAdapter";
+
+import { LocationType } from "../../locations";
+import getApps from "../../utils";
+import { createEvent, updateEvent } from "./calendarManager";
+import { createMeeting, updateMeeting } from "./videoClient";
+
+export type Event = AdditionInformation & VideoCallData;
+
+export const isZoom = (location: string): boolean => {
+ return location === "integrations:zoom";
+};
+
+export const isDaily = (location: string): boolean => {
+ return location === "integrations:daily";
+};
+
+export const isHuddle01 = (location: string): boolean => {
+ return location === "integrations:huddle01";
+};
+
+export const isTandem = (location: string): boolean => {
+ return location === "integrations:tandem";
+};
+
+export const isTeams = (location: string): boolean => {
+ return location === "integrations:office365_video";
+};
+
+export const isJitsi = (location: string): boolean => {
+ return location === "integrations:jitsi";
+};
+
+export const isDedicatedIntegration = (location: string): boolean => {
+ return (
+ isZoom(location) ||
+ isDaily(location) ||
+ isHuddle01(location) ||
+ isTandem(location) ||
+ isJitsi(location) ||
+ isTeams(location)
+ );
+};
+
+export const getLocationRequestFromIntegration = (location: string) => {
+ if (
+ /** TODO: Handle this dynamically */
+ location === LocationType.GoogleMeet.valueOf() ||
+ location === LocationType.Zoom.valueOf() ||
+ location === LocationType.Daily.valueOf() ||
+ location === LocationType.Jitsi.valueOf() ||
+ location === LocationType.Huddle01.valueOf() ||
+ location === LocationType.Tandem.valueOf() ||
+ location === LocationType.Teams.valueOf()
+ ) {
+ const requestId = uuidv5(location, uuidv5.URL);
+
+ return {
+ conferenceData: {
+ createRequest: {
+ requestId: requestId,
+ },
+ },
+ location,
+ };
+ }
+
+ return null;
+};
+
+export const processLocation = (event: CalendarEvent): CalendarEvent => {
+ // If location is set to an integration location
+ // Build proper transforms for evt object
+ // Extend evt object with those transformations
+ if (event.location?.includes("integration")) {
+ const maybeLocationRequestObject = getLocationRequestFromIntegration(event.location);
+
+ event = merge(event, maybeLocationRequestObject);
+ }
+
+ return event;
+};
+
+type EventManagerUser = {
+ credentials: Credential[];
+ destinationCalendar: DestinationCalendar | null;
+};
+
+export default class EventManager {
+ calendarCredentials: Credential[];
+ videoCredentials: Credential[];
+
+ /**
+ * Takes an array of credentials and initializes a new instance of the EventManager.
+ *
+ * @param user
+ */
+ constructor(user: EventManagerUser) {
+ const appCredentials = getApps(user.credentials).flatMap((app) => app.credentials);
+ this.calendarCredentials = appCredentials.filter((cred) => cred.type.endsWith("_calendar"));
+ this.videoCredentials = appCredentials.filter((cred) => cred.type.endsWith("_video"));
+ }
+
+ /**
+ * Takes a CalendarEvent and creates all necessary integration entries for it.
+ * When a video integration is chosen as the event's location, a video integration
+ * event will be scheduled for it as well.
+ *
+ * @param event
+ */
+ public async create(event: CalendarEvent): Promise {
+ const evt = processLocation(event);
+ const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
+
+ const results: Array = [];
+ // If and only if event type is a dedicated meeting, create a dedicated video meeting.
+ if (isDedicated) {
+ const result = await this.createVideoEvent(evt);
+ if (result.createdEvent) {
+ evt.videoCallData = result.createdEvent;
+ }
+
+ results.push(result);
+ }
+
+ // Create the calendar event with the proper video call data
+ results.push(...(await this.createAllCalendarEvents(evt)));
+
+ const referencesToCreate: Array = results.map((result: EventResult) => {
+ return {
+ type: result.type,
+ uid: result.createdEvent?.id.toString() ?? "",
+ meetingId: result.createdEvent?.id.toString(),
+ meetingPassword: result.createdEvent?.password,
+ meetingUrl: result.createdEvent?.url,
+ };
+ });
+
+ return {
+ results,
+ referencesToCreate,
+ };
+ }
+
+ /**
+ * Takes a calendarEvent and a rescheduleUid and updates the event that has the
+ * given uid using the data delivered in the given CalendarEvent.
+ *
+ * @param event
+ */
+ public async update(
+ event: CalendarEvent,
+ rescheduleUid: string,
+ newBookingId?: number
+ ): Promise {
+ const evt = processLocation(event);
+
+ if (!rescheduleUid) {
+ throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen.");
+ }
+
+ // Get details of existing booking.
+ const booking = await prisma.booking.findFirst({
+ where: {
+ uid: rescheduleUid,
+ },
+ select: {
+ id: true,
+ references: {
+ // NOTE: id field removed from select as we don't require for deletingMany
+ // but was giving error on recreate for reschedule, probably because promise.all() didn't finished
+ select: {
+ type: true,
+ uid: true,
+ meetingId: true,
+ meetingPassword: true,
+ meetingUrl: true,
+ },
+ },
+ destinationCalendar: true,
+ payment: true,
+ },
+ });
+
+ if (!booking) {
+ throw new Error("booking not found");
+ }
+
+ const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
+ const results: Array = [];
+ // If and only if event type is a dedicated meeting, update the dedicated video meeting.
+ if (isDedicated) {
+ const result = await this.updateVideoEvent(evt, booking);
+ const [updatedEvent] = Array.isArray(result.updatedEvent) ? result.updatedEvent : [result.updatedEvent];
+ if (updatedEvent) {
+ evt.videoCallData = updatedEvent;
+ evt.location = updatedEvent.url;
+ }
+ results.push(result);
+ }
+
+ // Update all calendar events.
+ results.push(...(await this.updateAllCalendarEvents(evt, booking)));
+
+ const bookingPayment = booking?.payment;
+
+ // Updating all payment to new
+ if (bookingPayment && newBookingId) {
+ const paymentIds = bookingPayment.map((payment) => payment.id);
+ await prisma.payment.updateMany({
+ where: {
+ id: {
+ in: paymentIds,
+ },
+ },
+ data: {
+ bookingId: newBookingId,
+ },
+ });
+ }
+
+ // Now we can delete the old booking and its references.
+ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
+ where: {
+ bookingId: booking.id,
+ },
+ });
+ const attendeeDeletes = prisma.attendee.deleteMany({
+ where: {
+ bookingId: booking.id,
+ },
+ });
+
+ const bookingDeletes = prisma.booking.delete({
+ where: {
+ id: booking.id,
+ },
+ });
+
+ // Wait for all deletions to be applied.
+ await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
+
+ return {
+ results,
+ referencesToCreate: [...booking.references],
+ };
+ }
+
+ /**
+ * Creates event entries for all calendar integrations given in the credentials.
+ * When noMail is true, no mails will be sent. This is used when the event is
+ * a video meeting because then the mail containing the video credentials will be
+ * more important than the mails created for these bare calendar events.
+ *
+ * When the optional uid is set, it will be used instead of the auto generated uid.
+ *
+ * @param event
+ * @param noMail
+ * @private
+ */
+ private async createAllCalendarEvents(event: CalendarEvent): Promise> {
+ /** Can I use destinationCalendar here? */
+ /* How can I link a DC to a cred? */
+ if (event.destinationCalendar) {
+ const destinationCalendarCredentials = this.calendarCredentials.filter(
+ (c) => c.type === event.destinationCalendar?.integration
+ );
+ return Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)));
+ }
+
+ /**
+ * Not ideal but, if we don't find a destination calendar,
+ * fallback to the first connected calendar
+ */
+ const [credential] = this.calendarCredentials;
+ if (!credential) {
+ return [];
+ }
+ return [await createEvent(credential, event)];
+ }
+
+ /**
+ * Checks which video integration is needed for the event's location and returns
+ * credentials for that - if existing.
+ * @param event
+ * @private
+ */
+
+ private getVideoCredential(event: CalendarEvent): Credential | undefined {
+ if (!event.location) {
+ return undefined;
+ }
+
+ const integrationName = event.location.replace("integrations:", "");
+
+ return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
+ }
+
+ /**
+ * Creates a video event entry for the selected integration location.
+ *
+ * When optional uid is set, it will be used instead of the auto generated uid.
+ *
+ * @param event
+ * @private
+ */
+ private createVideoEvent(event: CalendarEvent): Promise {
+ const credential = this.getVideoCredential(event);
+
+ if (credential) {
+ return createMeeting(credential, event);
+ } else {
+ return Promise.reject("No suitable credentials given for the requested integration name.");
+ }
+ }
+
+ /**
+ * Updates the event entries for all calendar integrations given in the credentials.
+ * When noMail is true, no mails will be sent. This is used when the event is
+ * a video meeting because then the mail containing the video credentials will be
+ * more important than the mails created for these bare calendar events.
+ *
+ * @param event
+ * @param booking
+ * @private
+ */
+ private updateAllCalendarEvents(
+ event: CalendarEvent,
+ booking: PartialBooking
+ ): Promise> {
+ return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
+ const bookingRefUid = booking
+ ? booking.references.filter((ref) => ref.type === credential.type)[0]?.uid
+ : null;
+
+ return updateEvent(credential, event, bookingRefUid);
+ });
+ }
+
+ /**
+ * Updates a single video event.
+ *
+ * @param event
+ * @param booking
+ * @private
+ */
+ private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
+ const credential = this.getVideoCredential(event);
+
+ if (credential) {
+ const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
+ return updateMeeting(credential, event, bookingRef);
+ } else {
+ return Promise.reject("No suitable credentials given for the requested integration name.");
+ }
+ }
+
+ /**
+ * Update event to set a cancelled event placeholder on users calendar
+ * remove if virtual calendar is already done and user availability its read from there
+ * and not only in their calendars
+ * @param event
+ * @param booking
+ * @public
+ */
+ public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) {
+ await this.updateAllCalendarEvents(event, booking);
+ }
+}
diff --git a/packages/app-store/wipemycalother/lib/index.ts b/packages/app-store/wipemycalother/lib/index.ts
new file mode 100644
index 00000000..7eac0f92
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/index.ts
@@ -0,0 +1 @@
+export { default as Reschedule } from "./reschedule";
diff --git a/packages/app-store/wipemycalother/lib/reschedule.ts b/packages/app-store/wipemycalother/lib/reschedule.ts
new file mode 100644
index 00000000..dc152669
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/reschedule.ts
@@ -0,0 +1,170 @@
+import { BookingStatus, User, Booking, BookingReference } from "@prisma/client";
+import dayjs from "dayjs";
+import type { TFunction } from "next-i18next";
+
+import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
+import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
+import logger from "@calcom/lib/logger";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import prisma from "@calcom/prisma";
+import { Person } from "@calcom/types/Calendar";
+
+import { getCalendar } from "../../_utils/getCalendar";
+import { sendRequestRescheduleEmail } from "./emailManager";
+import EventManager from "./eventManager";
+import { deleteMeeting } from "./videoClient";
+
+type PersonAttendeeCommonFields = Pick;
+
+const Reschedule = async (bookingUid: string, cancellationReason: string) => {
+ const bookingToReschedule = await prisma.booking.findFirst({
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ userId: true,
+ eventTypeId: true,
+ location: true,
+ attendees: true,
+ references: true,
+ user: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ timeZone: true,
+ locale: true,
+ username: true,
+ credentials: true,
+ destinationCalendar: true,
+ },
+ },
+ },
+ rejectOnNotFound: true,
+ where: {
+ uid: bookingUid,
+ NOT: {
+ status: {
+ in: [BookingStatus.CANCELLED, BookingStatus.REJECTED],
+ },
+ },
+ },
+ });
+
+ if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) {
+ const userOwner = bookingToReschedule.user;
+ const event = await prisma.eventType.findFirst({
+ select: {
+ title: true,
+ users: true,
+ schedulingType: true,
+ },
+ rejectOnNotFound: true,
+ where: {
+ id: bookingToReschedule.eventTypeId,
+ },
+ });
+ await prisma.booking.update({
+ where: {
+ id: bookingToReschedule.id,
+ },
+ data: {
+ rescheduled: true,
+ cancellationReason,
+ status: BookingStatus.CANCELLED,
+ updatedAt: dayjs().toISOString(),
+ },
+ });
+ const [mainAttendee] = bookingToReschedule.attendees;
+ // @NOTE: Should we assume attendees language?
+ const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
+ const usersToPeopleType = (
+ users: PersonAttendeeCommonFields[],
+ selectedLanguage: TFunction
+ ): Person[] => {
+ return users?.map((user) => {
+ return {
+ email: user.email || "",
+ name: user.name || "",
+ username: user?.username || "",
+ language: { translate: selectedLanguage, locale: user.locale || "en" },
+ timeZone: user?.timeZone,
+ };
+ });
+ };
+ const userOwnerTranslation = await getTranslation(userOwner.locale ?? "en", "common");
+ const [userOwnerAsPeopleType] = usersToPeopleType([userOwner], userOwnerTranslation);
+ const builder = new CalendarEventBuilder();
+ builder.init({
+ title: bookingToReschedule.title,
+ type: event.title,
+ startTime: bookingToReschedule.startTime.toISOString(),
+ endTime: bookingToReschedule.endTime.toISOString(),
+ attendees: usersToPeopleType(
+ // username field doesn't exists on attendee but could be in the future
+ bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
+ tAttendees
+ ),
+ organizer: userOwnerAsPeopleType,
+ });
+ const director = new CalendarEventDirector();
+ director.setBuilder(builder);
+ director.setExistingBooking(bookingToReschedule as unknown as Booking);
+ director.setCancellationReason(cancellationReason);
+ await director.buildForRescheduleEmail();
+ // Handling calendar and videos cancellation
+ // This can set previous time as available, until virtual calendar is done
+ const credentialsMap = new Map();
+ userOwner.credentials.forEach((credential) => {
+ credentialsMap.set(credential.type, credential);
+ });
+ const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
+ (ref) => !!credentialsMap.get(ref.type)
+ );
+ try {
+ bookingRefsFiltered.forEach((bookingRef) => {
+ if (bookingRef.uid) {
+ if (bookingRef.type.endsWith("_calendar")) {
+ const calendar = getCalendar(credentialsMap.get(bookingRef.type));
+ return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
+ } else if (bookingRef.type.endsWith("_video")) {
+ return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
+ }
+ }
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+ // Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it
+ try {
+ const eventManager = new EventManager({
+ credentials: userOwner.credentials,
+ destinationCalendar: userOwner.destinationCalendar,
+ });
+ builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`;
+ await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule);
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+
+ // Send emails
+ try {
+ await sendRequestRescheduleEmail(builder.calendarEvent, {
+ rescheduleLink: builder.rescheduleLink,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+ return true;
+ }
+};
+
+export default Reschedule;
diff --git a/packages/app-store/wipemycalother/lib/templates/attendee-request-reschedule-email.ts b/packages/app-store/wipemycalother/lib/templates/attendee-request-reschedule-email.ts
new file mode 100644
index 00000000..2572815b
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/attendee-request-reschedule-email.ts
@@ -0,0 +1,209 @@
+import dayjs from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+import timezone from "dayjs/plugin/timezone";
+import toArray from "dayjs/plugin/toArray";
+import utc from "dayjs/plugin/utc";
+import { createEvent, DateArray, Person } from "ics";
+
+import { getCancelLink } from "@calcom/lib/CalEventParser";
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import BaseTemplate from "./base-template";
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+} from "./common";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class AttendeeRequestRescheduledEmail extends BaseTemplate {
+ private metadata: { rescheduleLink: string };
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
+ super(calEvent);
+ this.metadata = metadata;
+ }
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.attendees[0].email];
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("requested_to_reschedule_subject_attendee", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ // @OVERRIDE
+ protected getiCalEventAsString(): string | undefined {
+ console.log("overriding");
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ description: this.getTextBody(),
+ duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
+ organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ attendees: this.calEvent.attendees.map((attendee: Person) => ({
+ name: attendee.name,
+ email: attendee.email,
+ })),
+ status: "CANCELLED",
+ method: "CANCEL",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+ // @OVERRIDE
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("request_reschedule_title_attendee")}
+${this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
+ organizer: this.calEvent.organizer.name,
+})},
+${this.getWhat()}
+${this.getWhen()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(this.calEvent)}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("request_reschedule_title_attendee"),
+ this.calEvent.organizer.language.translate("request_reschedule_subtitle", {
+ organizer: this.calEvent.organizer.name,
+ })
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getAdditionalNotes()}
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/packages/app-store/wipemycalother/lib/templates/base-template.ts b/packages/app-store/wipemycalother/lib/templates/base-template.ts
new file mode 100644
index 00000000..cf0479bc
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/base-template.ts
@@ -0,0 +1,409 @@
+import dayjs, { Dayjs } from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+import timezone from "dayjs/plugin/timezone";
+import toArray from "dayjs/plugin/toArray";
+import utc from "dayjs/plugin/utc";
+import { createEvent, DateArray, Person } from "ics";
+import nodemailer from "nodemailer";
+
+import { getAppName } from "@calcom/app-store/utils";
+import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
+import { getErrorFromUnknown } from "@calcom/lib/errors";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+
+import { serverConfig } from "../emailServerConfig";
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+ linkIcon,
+} from "./common";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class OrganizerScheduledEmail {
+ calEvent: CalendarEvent;
+
+ constructor(calEvent: CalendarEvent) {
+ this.calEvent = calEvent;
+ }
+
+ public sendEmail() {
+ new Promise((resolve, reject) =>
+ nodemailer
+ .createTransport(this.getMailerOptions().transport)
+ .sendMail(this.getNodeMailerPayload(), (_err, info) => {
+ if (_err) {
+ const err = getErrorFromUnknown(_err);
+ this.printNodeMailerError(err);
+ reject(err);
+ } else {
+ resolve(info);
+ }
+ })
+ ).catch((e) => console.error("sendEmail", e));
+ return new Promise((resolve) => resolve("send mail async"));
+ }
+
+ protected getiCalEventAsString(): string | undefined {
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ description: this.getTextBody(),
+ duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
+ organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ attendees: this.calEvent.attendees.map((attendee: Person) => ({
+ name: attendee.name,
+ email: attendee.email,
+ })),
+ status: "CONFIRMED",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.organizer.email];
+ if (this.calEvent.team) {
+ this.calEvent.team.members.forEach((member) => {
+ const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
+ if (memberAttendee) {
+ toAddresses.push(memberAttendee.email);
+ }
+ });
+ }
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("confirmed_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ protected getMailerOptions() {
+ return {
+ transport: serverConfig.transport,
+ from: serverConfig.from,
+ };
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("new_event_scheduled")}
+${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
+
+${getRichDescription(this.calEvent)}
+`.trim();
+ }
+
+ protected printNodeMailerError(error: Error): void {
+ console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error);
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("confirmed_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("checkCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("new_event_scheduled"),
+ this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getDescription()}
+ ${this.getAdditionalNotes()}
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getManageLink(): string {
+ const manageText = this.calEvent.organizer.language.translate("manage_this_event");
+ return `${this.calEvent.organizer.language.translate(
+ "need_to_reschedule_or_cancel"
+ )}
${manageText}
`;
+ }
+
+ protected getWhat(): string {
+ return `
+
+
${this.calEvent.organizer.language.translate("what")}
+
${this.calEvent.type}
+
`;
+ }
+
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getWho(): string {
+ const attendees = this.calEvent.attendees
+ .map((attendee) => {
+ return ``;
+ })
+ .join("");
+
+ const organizer = ``;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("who")}
+ ${organizer + attendees}
+
`;
+ }
+
+ protected getAdditionalNotes(): string {
+ if (!this.calEvent.additionalNotes) return "";
+ return `
+
+
+
${this.calEvent.organizer.language.translate("additional_notes")}
+
${
+ this.calEvent.additionalNotes
+ }
+
+ `;
+ }
+
+ protected getDescription(): string {
+ if (!this.calEvent.description) return "";
+ return `
+
+
+
${this.calEvent.organizer.language.translate("description")}
+
${
+ this.calEvent.description
+ }
+
+ `;
+ }
+
+ protected getLocation(): string {
+ let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : "";
+
+ if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
+ const location = this.calEvent.location.split(":")[1];
+ providerName = location[0].toUpperCase() + location.slice(1);
+ }
+
+ // If location its a url, probably we should be validating it with a custom library
+ if (this.calEvent.location && /^https?:\/\//.test(this.calEvent.location)) {
+ providerName = this.calEvent.location;
+ }
+
+ if (this.calEvent.videoCallData) {
+ const meetingId = this.calEvent.videoCallData.id;
+ const meetingPassword = this.calEvent.videoCallData.password;
+ const meetingUrl = this.calEvent.videoCallData.url;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${providerName} ${
+ meetingUrl &&
+ `
`
+ }
+ ${
+ meetingId &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_id"
+ )}: ${meetingId}
`
+ }
+ ${
+ meetingPassword &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_password"
+ )}: ${meetingPassword}
`
+ }
+ ${
+ meetingUrl &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_url"
+ )}:
${meetingUrl}`
+ }
+
+ `;
+ }
+
+ if (this.calEvent.additionInformation?.hangoutLink) {
+ const hangoutLink: string = this.calEvent.additionInformation.hangoutLink;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${providerName} ${
+ hangoutLink &&
+ `
`
+ }
+
+
+ `;
+ }
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${
+ providerName || this.calEvent.location
+ }
+
+ `;
+ }
+
+ protected getTimezone(): string {
+ return this.calEvent.organizer.timeZone;
+ }
+
+ protected getOrganizerStart(): Dayjs {
+ return dayjs(this.calEvent.startTime).tz(this.getTimezone());
+ }
+
+ protected getOrganizerEnd(): Dayjs {
+ return dayjs(this.calEvent.endTime).tz(this.getTimezone());
+ }
+}
diff --git a/packages/app-store/wipemycalother/lib/templates/common/body-logo.ts b/packages/app-store/wipemycalother/lib/templates/common/body-logo.ts
new file mode 100644
index 00000000..3b5b143e
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/body-logo.ts
@@ -0,0 +1,44 @@
+import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
+
+export const emailBodyLogo = (): string => {
+ const image = IS_PRODUCTION
+ ? BASE_URL + "/emails/CalLogo@2x.png"
+ : "https://app.cal.com/emails/CalLogo@2x.png";
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/wipemycalother/lib/templates/common/head.ts b/packages/app-store/wipemycalother/lib/templates/common/head.ts
new file mode 100644
index 00000000..be224038
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/head.ts
@@ -0,0 +1,91 @@
+export const emailHead = (headerContent: string): string => {
+ return `
+
+ ${headerContent}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/wipemycalother/lib/templates/common/index.ts b/packages/app-store/wipemycalother/lib/templates/common/index.ts
new file mode 100644
index 00000000..686d871f
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/index.ts
@@ -0,0 +1,6 @@
+export { emailHead } from "./head";
+export { emailSchedulingBodyHeader } from "./scheduling-body-head";
+export { emailBodyLogo } from "./body-logo";
+export { emailScheduledBodyHeaderContent } from "./scheduling-body-head-content";
+export { emailSchedulingBodyDivider } from "./scheduling-body-divider";
+export { linkIcon } from "./link-icon";
diff --git a/packages/app-store/wipemycalother/lib/templates/common/link-icon.ts b/packages/app-store/wipemycalother/lib/templates/common/link-icon.ts
new file mode 100644
index 00000000..434ae2cb
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/link-icon.ts
@@ -0,0 +1,5 @@
+import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
+
+export const linkIcon = (): string => {
+ return IS_PRODUCTION ? BASE_URL + "/emails/linkIcon.png" : "https://app.cal.com/emails/linkIcon.png";
+};
diff --git a/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-divider.ts b/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-divider.ts
new file mode 100644
index 00000000..b8723c3e
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-divider.ts
@@ -0,0 +1,31 @@
+export const emailSchedulingBodyDivider = (): string => {
+ return `
+
+
+ `;
+};
diff --git a/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-head-content.ts b/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-head-content.ts
new file mode 100644
index 00000000..31515fc7
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-head-content.ts
@@ -0,0 +1,33 @@
+export const emailScheduledBodyHeaderContent = (title: string, subtitle: string): string => {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${title}
+ |
+
+
+
+ ${subtitle}
+ |
+
+
+
+
+
+ |
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-head.ts b/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-head.ts
new file mode 100644
index 00000000..fc715d2a
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/common/scheduling-body-head.ts
@@ -0,0 +1,71 @@
+import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
+
+export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle";
+
+export const getHeadImage = (headerType: BodyHeadType): string => {
+ switch (headerType) {
+ case "checkCircle":
+ return IS_PRODUCTION
+ ? BASE_URL + "/emails/checkCircle@2x.png"
+ : "https://app.cal.com/emails/checkCircle@2x.png";
+ case "xCircle":
+ return IS_PRODUCTION
+ ? BASE_URL + "/emails/xCircle@2x.png"
+ : "https://app.cal.com/emails/xCircle@2x.png";
+ case "calendarCircle":
+ return IS_PRODUCTION
+ ? BASE_URL + "/emails/calendarCircle@2x.png"
+ : "https://app.cal.com/emails/calendarCircle@2x.png";
+ }
+};
+
+export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => {
+ const image = getHeadImage(headerType);
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ `;
+};
diff --git a/packages/app-store/wipemycalother/lib/templates/organizer-request-reschedule-email.ts b/packages/app-store/wipemycalother/lib/templates/organizer-request-reschedule-email.ts
new file mode 100644
index 00000000..1c34e08e
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/templates/organizer-request-reschedule-email.ts
@@ -0,0 +1,189 @@
+import dayjs from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+import timezone from "dayjs/plugin/timezone";
+import toArray from "dayjs/plugin/toArray";
+import utc from "dayjs/plugin/utc";
+import { createEvent, DateArray, Person } from "ics";
+
+import { getCancelLink } from "@calcom/lib/CalEventParser";
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import BaseTemplate from "./base-template";
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+} from "./common";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class OrganizerRequestRescheduledEmail extends BaseTemplate {
+ private metadata: { rescheduleLink: string };
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
+ super(calEvent);
+ this.metadata = metadata;
+ }
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.organizer.email];
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ // @OVERRIDE
+ protected getiCalEventAsString(): string | undefined {
+ console.log("overriding");
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ description: this.getTextBody(),
+ duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
+ organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
+ attendees: this.calEvent.attendees.map((attendee: Person) => ({
+ name: attendee.name,
+ email: attendee.email,
+ })),
+ status: "CANCELLED",
+ method: "CANCEL",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+ // @OVERRIDE
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+})}
+${this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+})},
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(this.calEvent)}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+ }),
+ this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+ })
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getAdditionalNotes()}
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/packages/app-store/wipemycalother/lib/videoClient.ts b/packages/app-store/wipemycalother/lib/videoClient.ts
new file mode 100644
index 00000000..063be0e7
--- /dev/null
+++ b/packages/app-store/wipemycalother/lib/videoClient.ts
@@ -0,0 +1,116 @@
+// @NOTE: This was copy from core since core uses app/store and creates circular dependency
+// @TODO: Improve import export on appstore/core
+import { Credential } from "@prisma/client";
+import short from "short-uuid";
+import { v5 as uuidv5 } from "uuid";
+
+import { getUid } from "@calcom/lib/CalEventParser";
+import logger from "@calcom/lib/logger";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+import type { EventResult, PartialReference } from "@calcom/types/EventManager";
+import type { VideoApiAdapter, VideoApiAdapterFactory } from "@calcom/types/VideoApiAdapter";
+
+import appStore from "../../index";
+
+const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
+
+const translator = short();
+
+// factory
+const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] =>
+ withCredentials.reduce((acc, cred) => {
+ const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
+ const app = appStore[appName as keyof typeof appStore];
+ if (app && "lib" in app && "VideoApiAdapter" in app.lib) {
+ const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
+ const videoAdapter = makeVideoApiAdapter(cred);
+ acc.push(videoAdapter);
+ return acc;
+ }
+ return acc;
+ }, []);
+
+const getBusyVideoTimes = (withCredentials: Credential[]) =>
+ Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) =>
+ results.reduce((acc, availability) => acc.concat(availability), [])
+ );
+
+const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => {
+ const uid: string = getUid(calEvent);
+
+ if (!credential) {
+ throw new Error(
+ "Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
+ );
+ }
+
+ const videoAdapters = getVideoAdapters([credential]);
+ const [firstVideoAdapter] = videoAdapters;
+ const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch((e) => {
+ log.error("createMeeting failed", e, calEvent);
+ });
+
+ if (!createdMeeting) {
+ return {
+ type: credential.type,
+ success: false,
+ uid,
+ originalEvent: calEvent,
+ };
+ }
+
+ return {
+ type: credential.type,
+ success: true,
+ uid,
+ createdEvent: createdMeeting,
+ originalEvent: calEvent,
+ };
+};
+
+const updateMeeting = async (
+ credential: Credential,
+ calEvent: CalendarEvent,
+ bookingRef: PartialReference | null
+): Promise => {
+ const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
+
+ let success = true;
+
+ const [firstVideoAdapter] = getVideoAdapters([credential]);
+ const updatedMeeting =
+ credential && bookingRef
+ ? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch((e) => {
+ log.error("updateMeeting failed", e, calEvent);
+ success = false;
+ return undefined;
+ })
+ : undefined;
+
+ if (!updatedMeeting) {
+ return {
+ type: credential.type,
+ success,
+ uid,
+ originalEvent: calEvent,
+ };
+ }
+
+ return {
+ type: credential.type,
+ success,
+ uid,
+ updatedEvent: updatedMeeting,
+ originalEvent: calEvent,
+ };
+};
+
+const deleteMeeting = (credential: Credential, uid: string): Promise => {
+ if (credential) {
+ return getVideoAdapters([credential])[0].deleteMeeting(uid);
+ }
+
+ return Promise.resolve({});
+};
+
+export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };
diff --git a/packages/app-store/wipemycalother/package.json b/packages/app-store/wipemycalother/package.json
new file mode 100644
index 00000000..106bb6d0
--- /dev/null
+++ b/packages/app-store/wipemycalother/package.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "name": "WipeMyCal",
+ "version": "0.1.0",
+ "main": "./index.ts",
+ "description": "Wipe My Cal is a Cal.com exclusive app that redefines what it looks like to reschedule multiple meetings at the same time. Simply install the app, and select ‘Wipe’ for whatever date you need to mass reschedule. Handle emergencies, unexpected sick days and last minute events with the simple click of a button.",
+ "dependencies": {
+ "@calcom/prisma": "*",
+ "queue": "^6.0.2"
+ },
+ "devDependencies": {
+ "@calcom/types": "*"
+ }
+}
diff --git a/packages/app-store/wipemycalother/static/icon.svg b/packages/app-store/wipemycalother/static/icon.svg
new file mode 100644
index 00000000..d8f2d80f
--- /dev/null
+++ b/packages/app-store/wipemycalother/static/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts
index 83def2e1..21bee96a 100644
--- a/packages/types/App.d.ts
+++ b/packages/types/App.d.ts
@@ -29,7 +29,7 @@ export interface App {
/** The icon to display in /apps/installed */
imageSrc: string;
/** TODO determine if we should use this instead of category */
- variant: "calendar" | "payment" | "conferencing";
+ variant: "calendar" | "payment" | "conferencing" | "other";
/** The slug for the app store public page inside `/apps/[slug] */
slug: string;
/** The category to which this app belongs, currently we have `calendar`, `payment` or `video` */