Merge https://github.com/123om123/cal.com into minimum-booking-notice-will-allow-hours-and-days

This commit is contained in:
123om123 2022-11-10 11:50:16 -05:00
commit 6eb2a3f567
62 changed files with 503 additions and 1245 deletions

View File

@ -354,56 +354,6 @@ following
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
### Obtaining Slack Client ID and Secret and Signing Secret
To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps).
Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally.
<details>
<summary>App Manifest</summary>
```yaml
display_information:
name: Cal.com Slack
features:
bot_user:
display_name: Cal.com Slack
always_online: false
slash_commands:
- command: /create-event
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
description: Create an event within Cal!
should_escape: false
- command: /today
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
description: View all your bookings for today
should_escape: false
oauth_config:
redirect_urls:
- https://YOUR_DOMAIN/api/integrations/slackmessaging/callback
scopes:
bot:
- chat:write
- commands
- chat:write.public
settings:
interactivity:
is_enabled: true
request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
```
</details>
Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server.
Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today`
> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server.
### Obtaining Zoom Client ID and Secret

View File

@ -801,245 +801,247 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
{embed.title}
</h3>
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
<Collapsible
open={isEmbedCustomizationOpen}
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
<CollapsibleTrigger
type="button"
className="flex w-full items-center text-base font-medium text-neutral-900">
<div>
{embedType === "inline"
? "Inline Embed Customization"
: embedType === "floating-popup"
? "Floating Popup Customization"
: "Element Click Customization"}
</div>
<Icon.FiChevronRight
className={`${
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="text-sm">
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
{/*TODO: Add Auto/Fixed toggle from Figma */}
<div className="text-sm">Embed Window Sizing</div>
<div className="justify-left flex items-center">
<TextField
name="width"
labelProps={{ className: "hidden" }}
required
value={previewState.inline.width}
onChange={(e) => {
setPreviewState((previewState) => {
const width = e.target.value || "100%";
return {
...previewState,
inline: {
...previewState.inline,
width,
},
};
});
}}
addOnLeading={<InputLeading>W</InputLeading>}
/>
<span className="p-2">×</span>
<TextField
labelProps={{ className: "hidden" }}
name="height"
value={previewState.inline.height}
required
onChange={(e) => {
const height = e.target.value || "100%";
setPreviewState((previewState) => {
return {
...previewState,
inline: {
...previewState.inline,
height,
},
};
});
}}
addOnLeading={<InputLeading>H</InputLeading>}
/>
<div className="max-h-97 flex flex-col overflow-y-auto">
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
<Collapsible
open={isEmbedCustomizationOpen}
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
<CollapsibleTrigger
type="button"
className="flex w-full items-center text-base font-medium text-neutral-900">
<div>
{embedType === "inline"
? "Inline Embed Customization"
: embedType === "floating-popup"
? "Floating Popup Customization"
: "Element Click Customization"}
</div>
</div>
<div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "" : "hidden"
)}>
<div className="mb-2 text-sm">Button Text</div>
{/* Default Values should come from preview iframe */}
<TextField
name="buttonText"
labelProps={{ className: "hidden" }}
onChange={(e) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonText: e.target.value,
},
};
});
}}
defaultValue="Book my Cal"
required
<Icon.FiChevronRight
className={`${
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
/>
</div>
<div
className={classNames(
"mt-4 flex items-center justify-start",
embedType === "floating-popup" ? "space-x-2" : "hidden"
)}>
<Switch
defaultChecked={true}
onCheckedChange={(checked) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
hideButtonIcon: !checked,
},
};
});
}}
/>
<div className="text-sm">Display Calendar Icon Button</div>
</div>
<div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "" : "hidden"
)}>
<div className="mb-2">Position of Button</div>
<Select
onChange={(position) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonPosition: position?.value,
},
};
});
}}
defaultValue={FloatingPopupPositionOptions[0]}
options={FloatingPopupPositionOptions}
/>
</div>
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
<div>Button Color</div>
<div className="w-full">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
</CollapsibleTrigger>
<CollapsibleContent className="text-sm">
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
{/*TODO: Add Auto/Fixed toggle from Figma */}
<div className="text-sm">Embed Window Sizing</div>
<div className="justify-left flex items-center">
<TextField
name="width"
labelProps={{ className: "hidden" }}
required
value={previewState.inline.width}
onChange={(e) => {
setPreviewState((previewState) => {
const width = e.target.value || "100%";
return {
...previewState,
inline: {
...previewState.inline,
width,
},
};
});
}}
addOnLeading={<InputLeading>W</InputLeading>}
/>
<span className="p-2">×</span>
<TextField
labelProps={{ className: "hidden" }}
name="height"
value={previewState.inline.height}
required
onChange={(e) => {
const height = e.target.value || "100%";
setPreviewState((previewState) => {
return {
...previewState,
inline: {
...previewState.inline,
height,
},
};
});
}}
addOnLeading={<InputLeading>H</InputLeading>}
/>
</div>
</div>
<div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "" : "hidden"
)}>
<div className="mb-2 text-sm">Button Text</div>
{/* Default Values should come from preview iframe */}
<TextField
name="buttonText"
labelProps={{ className: "hidden" }}
onChange={(e) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonColor: color,
buttonText: e.target.value,
},
};
});
}}
defaultValue="Book my Cal"
required
/>
</div>
</div>
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
<div>Text Color</div>
<div className="w-full">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
<div
className={classNames(
"mt-4 flex items-center justify-start",
embedType === "floating-popup" ? "space-x-2" : "hidden"
)}>
<Switch
defaultChecked={true}
onCheckedChange={(checked) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonTextColor: color,
hideButtonIcon: !checked,
},
};
});
}}
/>
<div className="text-sm">Display Calendar Icon Button</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<hr className="mt-4" />
<div className="mt-4 font-medium">
<Collapsible
open={isBookingCustomizationOpen}
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
<CollapsibleTrigger className="flex w-full" type="button">
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
<Icon.FiChevronRight
className={`${
isBookingCustomizationOpen ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-6 text-sm">
<Label className="">
<div className="mb-2">Theme</div>
<div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "" : "hidden"
)}>
<div className="mb-2">Position of Button</div>
<Select
className="w-full"
defaultValue={ThemeOptions[0]}
components={{
Control: ThemeSelectControl,
}}
onChange={(option) => {
if (!option) {
return;
}
onChange={(position) => {
setPreviewState((previewState) => {
return {
...previewState,
theme: option.value,
floatingPopup: {
...previewState.floatingPopup,
buttonPosition: position?.value,
},
};
});
}}
options={ThemeOptions}
defaultValue={FloatingPopupPositionOptions[0]}
options={FloatingPopupPositionOptions}
/>
</Label>
{[
{ name: "brandColor", title: "Brand Color" },
// { name: "lightColor", title: "Light Color" },
// { name: "lighterColor", title: "Lighter Color" },
// { name: "lightestColor", title: "Lightest Color" },
// { name: "highlightColor", title: "Highlight Color" },
// { name: "medianColor", title: "Median Color" },
].map((palette) => (
<Label key={palette.name} className="pb-4">
<div className="mb-2 pt-2">{palette.title}</div>
<div className="w-full">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
addToPalette({
[palette.name as keyof typeof previewState["palette"]]: color,
});
}}
/>
</div>
</div>
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
<div>Button Color</div>
<div className="w-full">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonColor: color,
},
};
});
}}
/>
</div>
</div>
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
<div>Text Color</div>
<div className="w-full">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
setPreviewState((previewState) => {
return {
...previewState,
floatingPopup: {
...previewState.floatingPopup,
buttonTextColor: color,
},
};
});
}}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<hr className="mt-4" />
<div className="mt-4 font-medium">
<Collapsible
open={isBookingCustomizationOpen}
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
<CollapsibleTrigger className="flex w-full" type="button">
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
<Icon.FiChevronRight
className={`${
isBookingCustomizationOpen ? "rotate-90 transform" : ""
} ml-auto h-5 w-5 text-neutral-500`}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-6 text-sm">
<Label className="">
<div className="mb-2">Theme</div>
<Select
className="w-full"
defaultValue={ThemeOptions[0]}
components={{
Control: ThemeSelectControl,
}}
onChange={(option) => {
if (!option) {
return;
}
setPreviewState((previewState) => {
return {
...previewState,
theme: option.value,
};
});
}}
options={ThemeOptions}
/>
</Label>
))}
</div>
</CollapsibleContent>
</Collapsible>
{[
{ name: "brandColor", title: "Brand Color" },
// { name: "lightColor", title: "Light Color" },
// { name: "lighterColor", title: "Lighter Color" },
// { name: "lightestColor", title: "Lightest Color" },
// { name: "highlightColor", title: "Highlight Color" },
// { name: "medianColor", title: "Median Color" },
].map((palette) => (
<Label key={palette.name} className="pb-4">
<div className="mb-2 pt-2">{palette.title}</div>
<div className="w-full">
<ColorPicker
defaultValue="#000000"
onChange={(color) => {
addToPalette({
[palette.name as keyof typeof previewState["palette"]]: color,
});
}}
/>
</div>
</Label>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</div>
<div className="flex w-2/3 flex-col p-8">

View File

@ -14,7 +14,7 @@ import { Icon } from "@calcom/ui";
import { Checkbox, Button, TextField, Label } from "@calcom/ui/components";
import { CustomInputItem, Dialog, DialogContent, SettingsToggle, showToast, Tooltip } from "@calcom/ui/v2";
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
const generateHashedLink = (id: number) => {
const translator = short();

View File

@ -16,7 +16,7 @@ import { Select, Skeleton } from "@calcom/ui/v2";
import { slugify } from "@lib/slugify";
import { EditLocationDialog } from "@components/v2/eventtype/EditLocationDialog";
import { EditLocationDialog } from "@components/eventtype/EditLocationDialog";
type OptionTypeBase = {
label: string;

View File

@ -1,13 +1,12 @@
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
import { Icon } from "@calcom/ui/Icon";
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui/v2";
/** @deprecated Use `apps/web/components/v2/eventtype/SkeletonLoader.tsx` */
function SkeletonLoader() {
return (
<SkeletonContainer>
<div className="mb-4 flex items-center">
<SkeletonAvatar width="8" height="8" />
<div className="space-y-1">
<SkeletonAvatar className="h-8 w-8" />
<div className="flex flex-col space-y-1">
<SkeletonText className="h-4 w-16" />
<SkeletonText className="h-4 w-24" />
</div>
@ -28,17 +27,17 @@ function SkeletonItem() {
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div>
<SkeletonText width="32" height="5" />
<SkeletonText className="h-5 w-32" />
</div>
<div className="">
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
<li className="flex items-center whitespace-nowrap">
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
<SkeletonText width="12" height="4" />
<SkeletonText className="h-4 w-12" />
</li>
<li className="flex items-center whitespace-nowrap">
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
<SkeletonText width="16" height="4" />
<SkeletonText className="h-4 w-16" />
</li>
</ul>
</div>

View File

@ -1,47 +0,0 @@
import { Icon } from "@calcom/ui/Icon";
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui/v2";
function SkeletonLoader() {
return (
<SkeletonContainer>
<div className="mb-4 flex items-center">
<SkeletonAvatar className="h-8 w-8" />
<div className="flex flex-col space-y-1">
<SkeletonText className="h-4 w-16" />
<SkeletonText className="h-4 w-24" />
</div>
</div>
<ul className="divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
</SkeletonContainer>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div>
<SkeletonText className="h-5 w-32" />
</div>
<div className="">
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
<li className="flex items-center whitespace-nowrap">
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
<SkeletonText className="h-4 w-12" />
</li>
<li className="flex items-center whitespace-nowrap">
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
<SkeletonText className="h-4 w-16" />
</li>
</ul>
</div>
</div>
</li>
);
}

View File

@ -28,16 +28,16 @@ import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { AvailabilityTab } from "@components/v2/eventtype/AvailabilityTab";
import { AvailabilityTab } from "@components/eventtype/AvailabilityTab";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings
import { EventAdvancedTab } from "@components/v2/eventtype/EventAdvancedTab";
import { EventAppsTab } from "@components/v2/eventtype/EventAppsTab";
import { EventLimitsTab } from "@components/v2/eventtype/EventLimitsTab";
import { EventRecurringTab } from "@components/v2/eventtype/EventRecurringTab";
import { EventSetupTab } from "@components/v2/eventtype/EventSetupTab";
import { EventTeamTab } from "@components/v2/eventtype/EventTeamTab";
import { EventTypeSingleLayout } from "@components/v2/eventtype/EventTypeSingleLayout";
import EventWorkflowsTab from "@components/v2/eventtype/EventWorkfowsTab";
import { EventAdvancedTab } from "@components/eventtype/EventAdvancedTab";
import { EventAppsTab } from "@components/eventtype/EventAppsTab";
import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
import { EventTeamTab } from "@components/eventtype/EventTeamTab";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
import { getTranslation } from "@server/lib/i18n";

View File

@ -28,9 +28,9 @@ import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];

View File

@ -85,7 +85,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = {
...eventTypeRaw,
//TODO: Use zodSchema to verify it instead of using Type Assertion
locations: privacyFilteredLocations(eventTypeRaw.locations as LocationObject[]),
locations: privacyFilteredLocations((eventTypeRaw.locations || []) as LocationObject[]),
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
};

View File

@ -35,16 +35,16 @@ test.describe("Wipe my Cal App Test", () => {
await bookings.create(pro.id, pro.username, eventType.id, {});
await pro.login();
await page.goto("/bookings/upcoming");
await expect(page.locator("data-testid=wipe-today-button")).toBeVisible();
const $openBookingCount = await page.locator('[data-testid="bookings"] > *').count();
await expect($openBookingCount).toBe(3);
expect($openBookingCount).toBe(3);
await page.locator("data-testid=wipe-today-button").click();
await page.locator("data-testid=send_request").click();
const $openBookings = await page.locator('[data-testid="bookings"]');
// Don't await send_request click, otherwise mutation can possibly occur before observer is attached
page.locator("data-testid=send_request").click();
const $openBookings = page.locator('[data-testid="bookings"]');
await $openBookings.evaluate((ul) => {
return new Promise<void>((resolve) =>
new window.MutationObserver(() => {

View File

@ -126,7 +126,6 @@
"webhook_updated_successfully": "Webhook updated successfully!",
"webhook_removed_successfully": "Webhook removed successfully!",
"payload_template": "Payload Template",
"payload":"Payload",
"dismiss": "Dismiss",
"no_data_yet": "No data yet",
"ping_test": "Ping test",
@ -782,7 +781,7 @@
"analytics": "Analytics",
"empty_installed_apps_headline": "No apps installed",
"empty_installed_apps_description": "Apps enable you to enhance your workflow and improve your scheduling life significantly.",
"empty_installed_apps_button": "Explore the App Store or Install from below apps",
"empty_installed_apps_button": "Browse App Store",
"manage_your_connected_apps": "Manage your installed apps or change settings",
"browse_apps": "Browse Apps",
"features": "Features",
@ -1345,5 +1344,9 @@
"attendee_email_info": "The person booking's email",
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule."
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
"test_routing_form": "Test Routing Form",
"test_preview": "Test Preview",
"route_to": "Route to",
"test_preview_description": "Test your routing form without submitting any data"
}

View File

@ -20,7 +20,6 @@ export const InstallAppButtonMap = {
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
riverside: dynamic(() => import("./riverside/components/InstallAppButton")),
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
vital: dynamic(() => import("./vital/components/InstallAppButton")),
whereby: dynamic(() => import("./whereby/components/InstallAppButton")),

View File

@ -32,7 +32,6 @@ import { metadata as raycast_meta } from "./raycast/_metadata";
import { metadata as riverside_meta } from "./riverside/_metadata";
import { metadata as sendgridothercalendar_meta } from "./sendgridothercalendar/_metadata";
import { metadata as sirius_video_meta } from "./sirius_video/_metadata";
import { metadata as slackmessaging_meta } from "./slackmessaging/_metadata";
import { metadata as stripepayment_meta } from "./stripepayment/_metadata";
import { metadata as tandemvideo_meta } from "./tandemvideo/_metadata";
import { metadata as typeform_meta } from "./typeform/_metadata";
@ -74,7 +73,6 @@ export const appStoreMetadata = {
riverside: riverside_meta,
sendgridothercalendar: sendgridothercalendar_meta,
sirius_video: sirius_video_meta,
slackmessaging: slackmessaging_meta,
stripepayment: stripepayment_meta,
tandemvideo: tandemvideo_meta,
typeform: typeform_meta,

View File

@ -31,7 +31,6 @@ export const apiHandlers = {
riverside: import("./riverside/api"),
sendgridothercalendar: import("./sendgridothercalendar/api"),
sirius_video: import("./sirius_video/api"),
slackmessaging: import("./slackmessaging/api"),
stripepayment: import("./stripepayment/api"),
tandemvideo: import("./tandemvideo/api"),
typeform: import("./typeform/api"),

View File

@ -0,0 +1,73 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { Dispatch, SetStateAction } from "react";
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
import { SerializableForm, Response } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;
response: Response;
setResponse: Dispatch<SetStateAction<Response>>;
};
export default function FormInputFields(props: Props) {
const { form, response, setResponse } = props;
const queryBuilderConfig = getQueryBuilderConfig(form);
return (
<>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700 dark:text-white">
{field.label}
</label>
</div>
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="field"
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
</div>
</div>
);
})}
</>
);
}

View File

@ -1,11 +1,12 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm, UseFormReturn, Controller } from "react-hook-form";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Dialog, DialogContent, DialogClose, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Button, ButtonGroup } from "@calcom/ui/components";
import { Form, TextAreaField, TextField } from "@calcom/ui/components/form";
import { showToast, DropdownMenuSeparator, Tooltip, VerticalDivider } from "@calcom/ui/v2";
@ -14,8 +15,12 @@ import SettingsToggle from "@calcom/ui/v2/core/SettingsToggle";
import { ShellMain } from "@calcom/ui/v2/core/Shell";
import Banner from "@calcom/ui/v2/core/banner";
import { processRoute } from "../lib/processRoute";
import { RoutingPages } from "../pages/route-builder/[...appPages]";
import { SerializableForm } from "../types/types";
import { Response, Route } from "../types/types";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar";
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
@ -190,6 +195,15 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
const utils = trpc.useContext();
const { t } = useLocale();
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<Response>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
function testRouting() {
const action = processRoute({ form, response });
setDecidedAction(action);
}
const hookForm = useForm({
defaultValues: form,
});
@ -210,76 +224,151 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
},
});
return (
<Form
form={hookForm}
handleSubmit={(data) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
mutation.mutate({
...data,
});
}}>
<FormActionsProvider appUrl={appUrl}>
<Meta title={form.name} description={form.description || ""} />
<ShellMain
heading={form.name}
subtitle={form.description || ""}
backPath={`/${appUrl}/forms`}
CTA={<Actions form={form} mutation={mutation} />}>
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
<div className="flex flex-col items-center md:flex-row md:items-start">
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
<TextField
type="text"
containerClassName="mb-6"
placeholder="Title"
{...hookForm.register("name")}
/>
<TextAreaField
rows={3}
id="description"
data-testid="description"
placeholder="Form Description"
{...hookForm.register("description")}
defaultValue={form.description || ""}
/>
<>
<Form
form={hookForm}
handleSubmit={(data) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
mutation.mutate({
...data,
});
}}>
<FormActionsProvider appUrl={appUrl}>
<Meta title={form.name} description={form.description || ""} />
<ShellMain
heading={form.name}
subtitle={form.description || ""}
backPath={`/${appUrl}/forms`}
CTA={<Actions form={form} mutation={mutation} />}>
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
<div className="flex flex-col items-center md:flex-row md:items-start">
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
<TextField
type="text"
containerClassName="mb-6"
placeholder="Title"
{...hookForm.register("name")}
/>
<TextAreaField
rows={3}
id="description"
data-testid="description"
placeholder="Form Description"
{...hookForm.register("description")}
defaultValue={form.description || ""}
/>
<div className="mt-6">
<Controller
name="settings.emailOwnerOnSubmission"
control={hookForm.control}
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("routing_forms_send_email_owner")}
description={t("routing_forms_send_email_owner_description")}
checked={value}
onCheckedChange={(val) => onChange(val)}
/>
);
}}
/>
<div className="mt-6">
<Controller
name="settings.emailOwnerOnSubmission"
control={hookForm.control}
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("routing_forms_send_email_owner")}
description={t("routing_forms_send_email_owner_description")}
checked={value}
onCheckedChange={(val) => onChange(val)}
/>
);
}}
/>
</div>
<div className="mt-6">
<Button color="secondary" onClick={() => setIsTestPreviewOpen(true)}>
{t("test_preview")}
</Button>
</div>
{!form._count?.responses && (
<Banner
className="mt-6"
variant="neutral"
title="No Responses yet"
description="Wait for some time for responses to be collected. You can go and submit the form yourself as well."
Icon={Icon.FiInfo}
onDismiss={() => console.log("dismissed")}
/>
)}
</div>
<div className="w-full rounded-md border border-gray-200 p-8">
<RoutingNavBar appUrl={appUrl} form={form} />
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
{!form._count?.responses && (
<Banner
className="mt-6"
variant="neutral"
title="No Responses yet"
description="Wait for some time for responses to be collected. You can go and submit the form yourself as well."
Icon={Icon.FiInfo}
onDismiss={() => console.log("dismissed")}
/>
)}
</div>
<div className="w-full rounded-md border border-gray-200 p-8">
<RoutingNavBar appUrl={appUrl} form={form} />
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
</div>
</ShellMain>
</FormActionsProvider>
</Form>
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>
{decidedAction && (
<div className="mt-5 rounded-md bg-gray-100 p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value === decidedAction.type) {
return <>{page.label}</>;
}
})}
:{" "}
{decidedAction.type === "customPageMessage" ? (
<span className="text-gray-700">{decidedAction.value}</span>
) : decidedAction.type === "externalRedirectUrl" ? (
<span className="text-gray-700 underline">
<a
target="_blank"
href={
decidedAction.value.includes("https://") ||
decidedAction.value.includes("http://")
? decidedAction.value
: `http://${decidedAction.value}`
}
rel="noreferrer">
{decidedAction.value}
</a>
</span>
) : (
<span className="text-gray-700 underline">
<a target="_blank" href={`/${decidedAction.value}`} rel="noreferrer">
{decidedAction.value}
</a>
</span>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setDecidedAction(null);
setResponse({});
}}>
{t("close")}
</Button>
</DialogClose>
<Button type="submit">{t("Test Routing")}</Button>
</DialogFooter>
</form>
</div>
</ShellMain>
</FormActionsProvider>
</Form>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -104,7 +104,6 @@ const MultiSelectWidget = ({
return (
<Select
className="dark:border-darkgray-300 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
menuPosition="fixed"
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
@ -138,7 +137,6 @@ function SelectWidget({
return (
<Select
className="data-testid-select dark:border-darkgray-300 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
menuPosition="fixed"
onChange={(item) => {
if (!item) {
return;

View File

@ -128,6 +128,21 @@ type SerializableRoute = Pick<Route, "id" | "action"> & {
isFallback?: Route["isFallback"];
};
export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
},
{
label: "External Redirect",
value: "externalRedirectUrl",
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
},
];
const Route = ({
route,
routes,
@ -146,20 +161,7 @@ const Route = ({
moveDown?: { fn: () => void; check: () => boolean } | null;
}) => {
const index = routes.indexOf(route);
const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
},
{
label: "External Redirect",
value: "externalRedirectUrl",
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
},
];
const { data: eventTypesByGroup } = trpc.useQuery(["viewer.eventTypes"]);
const eventOptions: { label: string; value: string }[] = [];

View File

@ -18,10 +18,10 @@ import showToast from "@calcom/ui/v2/core/notifications";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import FormInputFields from "../../components/FormInputFields";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import { Response, Route } from "../../types/types";
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
@ -89,8 +89,6 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
const [response, setResponse] = useState<Response>({});
const queryBuilderConfig = getQueryBuilderConfig(form);
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
@ -124,57 +122,7 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
</p>
) : null}
</div>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700 dark:text-white">
{field.label}
</label>
</div>
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="field"
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
</div>
</div>
);
})}
<FormInputFields form={form} response={response} setResponse={setResponse} />
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast dark:border-transparent"

View File

@ -16,7 +16,6 @@ import * as larkcalendar from "./larkcalendar";
import * as office365calendar from "./office365calendar";
import * as office365video from "./office365video";
import * as sendgridothercalendar from "./sendgridothercalendar";
import * as slackmessaging from "./slackmessaging";
import * as stripepayment from "./stripepayment";
import * as tandemvideo from "./tandemvideo";
import * as vital from "./vital";
@ -39,7 +38,6 @@ const appStore = {
office365calendar,
office365video,
sendgridothercalendar,
slackmessaging,
stripepayment,
tandemvideo,
vital,

View File

@ -1,9 +0,0 @@
---
items:
- /api/app-store/slackmessaging/1.jpg
- /api/app-store/slackmessaging/2.jpg
- /api/app-store/slackmessaging/3.jpg
---
Slack is a proprietary business communication platform that includes many IRC (internet relay chat) features - these include channels, private groups, direct messaging and more. Users are able to send pictures, and videos as well as even hop on calls with others using the paid version. Slack is available via desktop app, web browser or mobile app. The Cal.com Slack App can be used to display your links, upcoming bookings or to create events. Use it with your team members in a Slack, in a Community Slack or even with external contributors in a Slack Connect Channel.

View File

@ -1,25 +0,0 @@
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "Slack App",
description: _package.description,
category: "messaging",
imageSrc: "/apps/slack.svg",
logo: "/apps/slack.svg",
publisher: "Cal.com",
rating: 5,
reviews: 69,
slug: "slack",
title: "Slack App",
trending: true,
// DB has type slack_app. It is an inconsistency
type: "slack_messaging",
url: "https://slack.com/",
variant: "conferencing",
verified: true,
email: "help@cal.com",
} as AppMeta;
export default metadata;

View File

@ -1,40 +0,0 @@
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { getSlackAppKeys } from "../lib/utils";
const scopes = ["commands", "users:read", "users:read.email", "chat:write", "chat:write.public"];
async function handler(req: NextApiRequest) {
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
const { client_id } = await getSlackAppKeys();
// Get user
await prisma.user.findFirstOrThrow({
where: {
id: req.session.user.id,
},
select: {
id: true,
},
});
const params = {
client_id,
scope: scopes.join(","),
};
const query = stringify(params);
const url = `https://slack.com/oauth/v2/authorize?${query}&user_`;
// const url =
// "https://slack.com/oauth/v2/authorize?client_id=3194129032064.3178385871204&scope=chat:write,commands&user_scope=";
return { url };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -1,61 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { z } from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { getSlackAppKeys } from "../lib/utils";
const callbackQuerySchema = z.object({
code: z.string().min(1),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
// Get user
const parsedCallbackQuery = callbackQuerySchema.safeParse(req.query);
if (!parsedCallbackQuery.success) {
return res.redirect("/apps/installed"); // Redirect to where the user was if they cancel the signup or if the oauth fails
}
const { code } = parsedCallbackQuery.data;
const { client_id, client_secret } = await getSlackAppKeys();
const query = {
client_secret,
client_id,
code,
};
const params = stringify(query);
const url = `https://slack.com/api/oauth.v2.access?${params}`;
const result = await fetch(url);
const responseBody = await result.json();
await prisma.user.update({
where: {
id: req.session.user.id,
},
data: {
credentials: {
create: {
type: "slack_app",
key: responseBody,
appId: "slack",
},
},
},
});
res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "slack" }));
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -1,39 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { showCreateEventMessage, showTodayMessage } from "../lib";
import showLinksMessage from "../lib/showLinksMessage";
export enum SlackAppCommands {
CREATE_EVENT = "create-event",
TODAY = "today",
LINKS = "links",
}
const commandHandlerBodySchema = z.object({
command: z.string().min(1),
user_id: z.string(),
trigger_id: z.string(),
channel_id: z.string().optional(),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = commandHandlerBodySchema.parse(req.body);
const command = body.command.split("/").pop();
switch (command) {
case SlackAppCommands.CREATE_EVENT:
return await showCreateEventMessage(req, res);
case SlackAppCommands.TODAY:
return await showTodayMessage(req, res);
case SlackAppCommands.LINKS:
return await showLinksMessage(req, res);
default:
return res.status(404).json({ message: `Command not found` });
}
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -1,4 +0,0 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as commandHandler } from "./commandHandler";
export { default as interactiveHandler } from "./interactiveHandler";

View File

@ -1,21 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import createEvent from "../lib/actions/createEvent";
enum InteractionEvents {
CREATE_EVENT = "cal.event.create",
}
export default async function interactiveHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const payload = JSON.parse(req.body.payload);
const actions = payload.view.callback_id;
switch (actions) {
case InteractionEvents.CREATE_EVENT:
await createEvent(req, res);
default:
return res.status(200).end(); // Techincally an invalid request but we don't want to return an throw an error to slack - 200 just does nothing
}
}
return res.status(200).end(); // Send 200 if we dont have a case for the action_id
}

View File

@ -1,18 +0,0 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("slack_messaging");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}

View File

@ -1 +0,0 @@
export { default as InstallAppButton } from "./InstallAppButton";

View File

@ -1,2 +0,0 @@
export * as api from "./api";
export { default } from "./_metadata";

View File

@ -1,9 +0,0 @@
export const WhereCredsEqualsId = (userId: string) => ({
where: {
type: "slack_app",
key: {
path: ["authed_user", "id"],
equals: userId,
},
},
});

View File

@ -1,119 +0,0 @@
import { WebClient } from "@slack/web-api";
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import db from "@calcom/prisma";
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import { WhereCredsEqualsId } from "../WhereCredsEqualsID";
import { getUserEmail } from "../utils";
export default async function createEvent(req: NextApiRequest, res: NextApiResponse) {
const {
user,
view: {
state: { values },
id: view_id,
},
response_url,
} = JSON.parse(req.body.payload);
// This is a mess I have no idea why slack makes getting infomation this hard.
const {
eventName: {
event_name: { value: selected_name },
},
eventType: {
"create.event.type": {
selected_option: { value: selected_event_id },
},
},
selectedUsers: {
invite_users: { selected_users },
},
eventDate: {
event_date: { selected_date },
},
eventTime: {
event_start_time: { selected_time },
},
} = values;
// Im sure this query can be made more efficient... The JSON filtering wouldnt work when doing it directly on user.
const foundUser = await db.credential
.findFirstOrThrow({
...WhereCredsEqualsId(user.id),
})
.user({
select: {
username: true,
email: true,
timeZone: true,
locale: true,
eventTypes: {
where: {
id: parseInt(selected_event_id),
},
select: {
id: true,
length: true,
locations: true,
},
},
credentials: {
...WhereCredsEqualsId(user.id),
},
},
});
const SlackCredentialsSchema = z.object({
access_token: z.string(),
});
const slackCredentials = SlackCredentialsSchema.parse(foundUser?.credentials[0].key); // Only one slack credential for user
const access_token = slackCredentials?.access_token;
// https://api.slack.com/authentication/best-practices#verifying since we verify the request is coming from slack we can store the access_token in the DB.
const client = new WebClient(access_token);
// This could get a bit weird as there is a 3 second limit until the post times ou
// Compute all users that have been selected and get their email.
const invitedGuestsEmails = selected_users.map((userId: string) => getUserEmail(client, userId));
const startDate = dayjs(`${selected_date} ${selected_time}`, "YYYY-MM-DD HH:mm");
const PostData: BookingCreateBody = {
start: dayjs(startDate).format(),
end: dayjs(startDate)
.add(foundUser?.eventTypes[0]?.length ?? 0, "minute")
.format(),
eventTypeId: foundUser?.eventTypes[0]?.id ?? 0,
user: foundUser?.username ?? "",
email: foundUser?.email ?? "",
name: foundUser?.username ?? "",
guests: await Promise.all(invitedGuestsEmails),
location: DailyLocationType, // Defaulting to daily video to make this a bit more usefull than in-person
timeZone: foundUser?.timeZone ?? "",
language: foundUser?.locale ?? "en",
customInputs: [{ label: "", value: "" }],
metadata: {},
notes: "This event was created with slack.",
};
const response = await fetch(`${WEBAPP_URL}/api/book/event`, {
method: "POST",
body: JSON.stringify(PostData),
headers: {
"Content-Type": "application/json",
},
});
const body = await response.json();
client.chat.postMessage({
token: access_token,
channel: user.id,
text: body.errorCode ? `Error: ${body.errorCode}` : "Booking has been created.",
});
return res.status(200).send("");
}

View File

@ -1,3 +0,0 @@
export { default as showCreateEventMessage } from "./showCreateEventMessage";
export { default as showTodayMessage } from "./showTodayMessage";
export * as utils from "./utils";

View File

@ -1,41 +0,0 @@
import { Prisma } from "@prisma/client";
import { WebClient } from "@slack/web-api";
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import slackVerify from "./slackVerify";
import { CreateEventModal, NoUserMessage } from "./views";
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
await slackVerify(req, res);
const data = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
user: {
select: {
username: true,
eventTypes: {
select: {
id: true,
title: true,
},
},
},
},
},
});
if (!data) return res.status(200).json(NoUserMessage);
const slackCredentials = data?.key; // Only one slack credential for user
const access_token = (slackCredentials as Prisma.JsonObject)?.access_token as string;
const slackClient = new WebClient(access_token);
await slackClient.views.open({
trigger_id: body.trigger_id,
view: CreateEventModal(data),
});
return res.status(200).end();
}

View File

@ -1,49 +0,0 @@
import { Prisma } from "@prisma/client";
import { WebClient } from "@slack/web-api";
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import slackVerify from "./slackVerify";
import { NoUserMessage } from "./views";
import ShowLinks from "./views/ShowLinks";
export default async function showLinksMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
await slackVerify(req, res);
const data = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
user: {
select: {
username: true,
eventTypes: {
where: {
hidden: false,
},
select: {
slug: true,
title: true,
},
},
},
},
},
});
if (!data) return res.status(200).json(NoUserMessage);
const slackCredentials = data?.key; // Only one slack credential for user
const access_token = (slackCredentials as Prisma.JsonObject)?.access_token as string;
const slackClient = new WebClient(access_token);
const blocks = JSON.parse(ShowLinks(data.user?.eventTypes, data.user?.username ?? "")).blocks;
slackClient.chat.postMessage({
channel: body.channel_id,
text: `${data.user?.username}'s Cal.com Links`,
blocks,
});
return res.status(200).end();
}

View File

@ -1,55 +0,0 @@
import { BookingStatus } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import slackVerify from "./slackVerify";
import { NoUserMessage, TodayMessage } from "./views";
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
await slackVerify(req, res);
const foundUser = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!foundUser) res.status(200).json(NoUserMessage);
const bookings = await prisma.booking.findMany({
where: {
OR: [
{
userId: foundUser?.userId,
},
{
attendees: {
some: {
email: foundUser?.user?.email,
},
},
},
],
AND: [
{
endTime: { gte: dayjs().startOf("day").toDate(), lte: dayjs().endOf("day").toDate() },
AND: [
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
},
],
},
});
return res.status(200).json(TodayMessage(bookings));
}

View File

@ -1,37 +0,0 @@
import { createHmac } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import dayjs from "@calcom/dayjs";
import { getSlackAppKeys } from "./utils";
export default async function slackVerify(req: NextApiRequest, res: NextApiResponse) {
const timeStamp = req.headers["x-slack-request-timestamp"] as string; // Always returns a string and not a string[]
const slackSignature = req.headers["x-slack-signature"] as string;
const currentTime = dayjs().unix();
const { signing_secret: signingSecret } = await getSlackAppKeys();
const [version, hash] = slackSignature.split("=");
if (!timeStamp) {
return res.status(400).json({ message: "Missing X-Slack-Request-Timestamp header" });
}
if (!signingSecret) {
return res.status(400).json({ message: "Missing Slack's signing_secret" });
}
if (Math.abs(currentTime - parseInt(timeStamp)) > 60 * 5) {
return res.status(400).json({ message: "Request is too old" });
}
const hmac = createHmac("sha256", signingSecret);
hmac.update(`${version}:${timeStamp}:${stringify(req.body)}`);
const signed_sig = hmac.digest("hex");
console.log({ signed_sig, hash, match: signed_sig === hash });
if (signed_sig !== hash) {
throw new Error("Hashes do not match ");
}
}

View File

@ -1,18 +0,0 @@
import { WebClient } from "@slack/web-api";
import { z } from "zod";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
export const getUserEmail = async (client: WebClient, userId: string) =>
(await client.users.info({ user: userId })).user?.profile?.email;
const slackAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
signing_secret: z.string(),
});
export const getSlackAppKeys = async () => {
const appKeys = await getAppKeysFromSlug("slack");
return slackAppKeysSchema.parse(appKeys);
};

View File

@ -1,9 +0,0 @@
import { Blocks, Message } from "slack-block-builder";
const BookingSuccess = () => {
return Message()
.blocks(Blocks.Section({ text: `Your booking has been created!` }))
.buildToObject();
};
export default BookingSuccess;

View File

@ -1,53 +0,0 @@
import { Credential } from "@prisma/client";
import { Bits, Blocks, Elements, Modal, setIfTruthy } from "slack-block-builder";
const CreateEventModal = (
data:
| (Credential & {
user: {
username: string | null;
eventTypes: {
id: number;
title: string;
}[];
} | null;
})
| null,
invalidInput = false
) => {
return Modal({ title: "Create Booking", submit: "Create", callbackId: "cal.event.create" })
.blocks(
Blocks.Section({ text: `Hey there, *${data?.user?.username}!*` }),
Blocks.Divider(),
Blocks.Input({ label: "Your Name", blockId: "eventName" }).element(
Elements.TextInput({ placeholder: "Event Name" }).actionId("event_name")
),
Blocks.Input({ label: "Which event would you like to create?", blockId: "eventType" }).element(
Elements.StaticSelect({ placeholder: "Which event would you like to create?" })
.actionId("create.event.type")
.options(
data?.user?.eventTypes.map((item: any) =>
Bits.Option({ text: item.title ?? "No Name", value: item.id.toString() })
)
)
), // This doesnt need to reach out to the server when the user changes the selection
Blocks.Input({
label: "Who would you like to invite to your event?",
blockId: "selectedUsers",
}).element(
Elements.UserMultiSelect({ placeholder: "Who would you like to invite to your event?" }).actionId(
"invite_users"
)
),
Blocks.Input({ label: "When would this event be?", blockId: "eventDate" }).element(
Elements.DatePicker({ placeholder: "Select Date" }).actionId("event_date")
),
Blocks.Input({ label: "What time would you like to start?", blockId: "eventTime" }).element(
Elements.TimePicker({ placeholder: "Select Time" }).actionId("event_start_time")
), // TODO: We could in future validate if the time is in the future or if busy at point - Didnt see much point as this gets validated when you submit. Could be better UX
setIfTruthy(invalidInput, [Blocks.Section({ text: "Please fill in all the fields" })])
)
.buildToObject();
};
export default CreateEventModal;

View File

@ -1,20 +0,0 @@
import { Message, Blocks, Elements } from "slack-block-builder";
import { BASE_URL } from "@calcom/lib/constants";
const NoUserMessage = () => {
return Message()
.blocks(
Blocks.Section({ text: "This slack account is not linked with a cal.com account" }),
Blocks.Actions().elements(
Elements.Button({ text: "Cancel", actionId: "cancel" }).danger(),
Elements.Button({
text: "Connect",
actionId: "open.connect.link",
url: `${BASE_URL}/apps/installed`,
}).primary()
)
)
.buildToJSON();
};
export default NoUserMessage;

View File

@ -1,31 +0,0 @@
import { Blocks, Elements, Message } from "slack-block-builder";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
interface IEventTypes {
slug: string;
title: string;
}
const ShowLinks = (eventLinks: IEventTypes[] | undefined, username: string) => {
if (eventLinks?.length === 0 || !eventLinks) {
return Message()
.blocks(Blocks.Section({ text: "You do not have any links." }))
.asUser()
.buildToJSON();
}
return Message()
.blocks(
Blocks.Section({ text: `${username}'s Cal.com Links` }),
Blocks.Divider(),
eventLinks.map((links) =>
Blocks.Section({
text: `${links.title} | ${WEBAPP_URL}/${username}/${links.slug}`,
}).accessory(Elements.Button({ text: "Open", url: `${WEBAPP_URL}/${username}/${links.slug}` }))
)
)
.buildToJSON();
};
export default ShowLinks;

View File

@ -1,27 +0,0 @@
import { Booking } from "@prisma/client";
import { Blocks, Elements, Message } from "slack-block-builder";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
const TodayMessage = (bookings: Booking[]) => {
if (bookings.length === 0) {
return Message()
.blocks(Blocks.Section({ text: "You do not have any bookings for today." }))
.asUser()
.buildToObject();
}
return Message()
.blocks(
Blocks.Section({ text: `Todays Bookings.` }),
Blocks.Divider(),
bookings.map((booking) =>
Blocks.Section({
text: `${booking.title} | ${dayjs(booking.startTime).format("HH:mm")}`,
}).accessory(Elements.Button({ text: "Cancel", url: `${WEBAPP_URL}/cancel/${booking.uid}` }))
)
)
.buildToObject();
};
export default TodayMessage;

View File

@ -1,3 +0,0 @@
export { default as CreateEventModal } from "./CreateEventModal";
export { default as TodayMessage } from "./TodayMessage";
export { default as NoUserMessage } from "./NoUser";

View File

@ -1,17 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/slackmessaging",
"version": "0.0.0",
"main": "./index.ts",
"description": "A business communication platform that includes persistent chat rooms (channels), private groups and direct messaging.",
"dependencies": {
"@calcom/prisma": "*",
"@slack/web-api": "^6.7.2",
"slack-block-builder": "^2.6.0",
"zod": "^3.19.1"
},
"devDependencies": {
"@calcom/types": "*"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

View File

@ -1,31 +0,0 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 270 270" style="enable-background:new 0 0 270 270;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E01E5A;}
.st1{fill:#36C5F0;}
.st2{fill:#2EB67D;}
.st3{fill:#ECB22E;}
</style>
<g>
<g>
<path class="st0" d="M99.4,151.2c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h12.9V151.2z"/>
<path class="st0" d="M105.9,151.2c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v32.3c0,7.1-5.8,12.9-12.9,12.9
s-12.9-5.8-12.9-12.9V151.2z"/>
</g>
<g>
<path class="st1" d="M118.8,99.4c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v12.9H118.8z"/>
<path class="st1" d="M118.8,105.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9H86.5c-7.1,0-12.9-5.8-12.9-12.9
s5.8-12.9,12.9-12.9H118.8z"/>
</g>
<g>
<path class="st2" d="M170.6,118.8c0-7.1,5.8-12.9,12.9-12.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9h-12.9V118.8z"/>
<path class="st2" d="M164.1,118.8c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9V86.5c0-7.1,5.8-12.9,12.9-12.9
c7.1,0,12.9,5.8,12.9,12.9V118.8z"/>
</g>
<g>
<path class="st3" d="M151.2,170.6c7.1,0,12.9,5.8,12.9,12.9c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9v-12.9H151.2z"/>
<path class="st3" d="M151.2,164.1c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h32.3c7.1,0,12.9,5.8,12.9,12.9
c0,7.1-5.8,12.9-12.9,12.9H151.2z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -2,13 +2,13 @@ import { WebhookTriggerEvents } from "@prisma/client";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Form, Label, TextArea, TextField } from "@calcom/ui/components/form";
import Switch from "@calcom/ui/v2/core/Switch";
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
import Select from "@calcom/ui/v2/core/form/select";
import customTemplate, { hasTemplateIntegration } from "../lib/integrationTemplate";
@ -217,28 +217,24 @@ const WebhookForm = (props: {
render={({ field: { value } }) => (
<>
<Label className="font-sm mt-8 text-gray-900">
<>{t("payload")}</>
<>{t("payload_template")}</>
</Label>
<div className="flex rounded-md border">
<div
className={classNames(
"px-1/2 w-1/2 rounded-md py-2.5 text-center font-medium text-gray-900",
!useCustomTemplate && "bg-gray-200"
)}
onClick={() => {
setUseCustomTemplate(false);
formMethods.setValue("payloadTemplate", undefined);
}}>
<p>{t("default")}</p>
</div>
<div
className={classNames(
"px-1/2 w-1/2 rounded-md py-2.5 text-center font-medium text-gray-900",
useCustomTemplate && "bg-gray-200"
)}
onClick={() => setUseCustomTemplate(true)}>
<p>{t("custom")}</p>
</div>
<div className="mb-2">
<ToggleGroup
onValueChange={(val) => {
if (val === "default") {
setUseCustomTemplate(false);
formMethods.setValue("payloadTemplate", undefined);
} else {
setUseCustomTemplate(true);
}
}}
defaultValue={value ? "custom" : "default"}
options={[
{ value: "default", label: t("default") },
{ value: "custom", label: t("custom") },
]}
/>
</div>
{useCustomTemplate && (
<TextArea

View File

@ -266,14 +266,7 @@ export default async function main() {
// Web3 apps
await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video");
// Messaging apps
if (process.env.SLACK_CLIENT_ID && process.env.SLACK_CLIENT_SECRET && process.env.SLACK_SIGNING_SECRET) {
await createApp("slack", "slackmessaging", ["messaging"], "slack_messaging", {
client_id: process.env.SLACK_CLIENT_ID,
client_secret: process.env.SLACK_CLIENT_SECRET,
signing_secret: process.env.SLACK_SIGNING_SECRET,
});
}
// Payment apps
if (
process.env.STRIPE_CLIENT_ID &&

View File

@ -69,7 +69,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
<DialogPrimitive.Content
{...props}
className={classNames(
"min-w-[360px] rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
"h-auto max-h-[inherit] min-w-[360px] rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
props.size == "xl"
? "p-0.5 sm:max-w-[98vw]"
: props.size == "lg"