Slack App Integration (#2041)
* patch applied * patch applied * We shouldn't pollute global css * Build fixes * Updates typings * WIP extracting zoom to package * Revert "Upgrades next to 12.1 (#1895)" (#1903) This reverts commitede0e98e1f
. * Tweak/gitignore prisma zod (#1905) * Extracts ignored createEventTypeBaseInput * Adds postinstall script * Revert "Tweak/gitignore prisma zod (#1905)" (#1906) This reverts commit15bfeb30d7
. * Eslint fixes (#1898) * Eslint fixes * Docs build fixes * Upgrade to next 12.1 (#1904) * Upgrades next to 12.1 * Fixes build * Updaters e2e test pipelines Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> * Fix URL by removing slash and backslash (#1733) * Fix URl by removing slash and backslash * Implement slugify * Add data type * Fixing folder structure * Solve zod-utils conflict * Build fixes (#1929) * Build fixes * Fixes type error * WIP * Conflict fixes * Removes unused file * TODO * WIP * Type fixes * Linting * WIP * Moved App definition to types * WIP * WIP * WIP * WIP WIP * Renamed zoomvideo app * Import fix * Daily.co app (#2022) * Daily.co app * Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts Co-authored-by: Omar López <zomars@me.com> * Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts Co-authored-by: Omar López <zomars@me.com> * Missing deps for newly added contants to lib Co-authored-by: Omar López <zomars@me.com> * WIP * WIP * WIP * Daily fixes * Updated type info * Slack Oauth integration - api route ideas * Adds getLocationOptions * Type fixes * Adds location option for daily video * Revert "Slack Oauth integration - api route ideas" This reverts commit 35ffa78e929339c4badb98cdab4e4b953ecc7cca. * Slack Oauth + verify sig * Slack Oauth + verify sig Implementing connect slack with workspace OAuth Implemented the ability for slack to send requests on events (commands etc) - This only works if slacks signature matches with our signature * Revert "Slack Oauth + verify sig" This reverts commit ee95795e0f0ae6d06be4e0a423afb8c315d9af7d. * WIP - Signature verifiaction failure * Huddle01 migration to app store (#2038) * Jitsi Video App migration * Removing uneeded dependencies * Missed unused reference * Missing dependency `@calcom/lib` is needed in the `locationOption.ts` file * Huddle01 migration to app store * WIP: PostData for creating event * Optimising Query Vital as we only have 3 seconds max to return the response to slack. * Jitsi Video App migration (#2027) * Jitsi Video App migration * Removing uneeded dependencies * Missed unused reference * Missing dependency `@calcom/lib` is needed in the `locationOption.ts` file Co-authored-by: Omar López <zomars@me.com> * Monorepo/app store MS Teams Integration (#2080) * Create teamsvideo package * Remove zoom specific refrences * Add teams video files * Rename to office365_video * Add call back to add crednetial type office365_teams * Rename to office_video to match type * Add MS Teams as a location option * Rename files * Add teams reponse interface and create meeting * Comment out Daily imports * Add check for Teams integration * Add token checking functions * Change template to create event rather than meeting * Add comment to test between create link and event * Add teams URL to booking * Ask for just onlineMeeting permission * Add MS Teams logo * Add message to have an enterprise account * Remove comments * Comment back hasDailyIntegration * Comment back daily credentials * Update link to MS Graph section of README * Move API calls to package Co-authored-by: Omar López <zomars@me.com> * Re-adds missing module for transpiling * Adding connect button if there is on user * Adds email as required field for app store metadata * WIP: migrates tandem to app store * Cleanup * Migrates tandem api routes to app store * Fixes tandem api handlers * Big WIP WIP * Show todays bookings. * No booking message to json * Transition into modals Better UX for submitting forms. * Create Bookings - Working * Fixing /today to show today and not all upcoming * Fixing message * Build fixes * WIP * Fixes annoying circular dependency bug I've spent a whole day on this.... * Location option cleanup * Type fixes * Update EventManager.ts * Update CalendarManager.ts * Merge branch 'monorepo/app-store' into sean-monorepo-slack-oauth * Moves CalendarService back to lib * Moves apple calendar to App Store * Cleanup * Booking Success * Merge branch 'main' into sean-monorepo-slack-oauth * Restored moved file * Delete TeamRole.tsx * Undoing unrelated changes * Cleanup * Cleanup * Updates website * Delete .env.example * Update yarn.lock * Adds instructions to README * Build fixes * Uses generic app store api handler * Adds install button and cleanup * Updates .env.example * Update README.md * Renames slackapp to slackmessaing * Update InstallAppButton.tsx * Delete locationOption.ts * Type fixes * Build fixes * Links + Fixing connection issue * fixed merge conflict * fixed merge conflict * Type fixes * Update index.ts Co-authored-by: zomars <zomars@me.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Juan Esteban Nieto Cifuentes <89233604+Jenietoc@users.noreply.github.com> Co-authored-by: Leo Giovanetti <hello@leog.me> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen <peer@cal.com>
This commit is contained in:
parent
41755c8c90
commit
02dbb88e6b
34 changed files with 1659 additions and 971 deletions
|
@ -9,6 +9,7 @@
|
|||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
|
@ -129,6 +130,12 @@ GOOGLE_LOGIN_ENABLED=false
|
|||
MS_GRAPH_CLIENT_ID=
|
||||
MS_GRAPH_CLIENT_SECRET=
|
||||
|
||||
# - SLACK
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
||||
SLACK_SIGNING_SECRET=
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# - STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
|
|
50
README.md
50
README.md
|
@ -317,6 +317,56 @@ We have a list of [good first issues](https://github.com/calcom/cal.com/labels/
|
|||
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
|
||||
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
|
||||
|
||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||
|
|
|
@ -31,12 +31,15 @@ import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
|||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||
|
||||
import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
const PhoneInput = dynamic(() => import("@components/ui/form/PhoneInput"));
|
||||
const PhoneInput = dynamic(
|
||||
() => import("@components/ui/form/PhoneInput")
|
||||
) as unknown as typeof PhoneInputType;
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||
|
||||
|
@ -383,8 +386,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
|
|||
{t("phone_number")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<PhoneInput
|
||||
// @ts-expect-error
|
||||
<PhoneInput<BookingFormValues>
|
||||
control={bookingForm.control}
|
||||
name="phone"
|
||||
placeholder={t("enter_phone_number")}
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import React from "react";
|
||||
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
type PhoneInputProps = {
|
||||
export type PhoneInputProps<FormValues> = Props<
|
||||
{
|
||||
value: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
};
|
||||
},
|
||||
FormValues
|
||||
>;
|
||||
|
||||
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
|
||||
function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<FormValues>) {
|
||||
return (
|
||||
<BasePhoneInput
|
||||
{...rest}
|
||||
name={name}
|
||||
|
@ -19,10 +22,8 @@ export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) =
|
|||
className={classNames(
|
||||
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
|
||||
)}
|
||||
onChange={() => {
|
||||
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
|
||||
}}
|
||||
/>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default PhoneInput;
|
||||
|
|
|
@ -9,11 +9,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
// Check that user is authenticated
|
||||
req.session = await getSession({ req });
|
||||
|
||||
if (!req.session?.user?.id) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { args } = req.query;
|
||||
|
||||
if (!Array.isArray(args)) {
|
||||
|
@ -38,7 +33,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const response = await handler(req, res);
|
||||
console.log("response", response);
|
||||
|
||||
res.status(200);
|
||||
return res.status(200);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
|
|
31
apps/web/public/apps/slack.svg
Normal file
31
apps/web/public/apps/slack.svg
Normal file
|
@ -0,0 +1,31 @@
|
|||
<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>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -1,7 +1,7 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { NEXT_PUBLIC_BASE_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
@ -14,6 +14,7 @@ export const InstallAppButtonMap = {
|
|||
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
|
||||
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
|
||||
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
|
||||
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
|
||||
stripepayment: dynamic(() => import("./stripepayment/components/InstallAppButton")),
|
||||
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
|
||||
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
|
||||
|
@ -36,9 +37,7 @@ export const InstallAppButton = (
|
|||
render={() => (
|
||||
<Button
|
||||
color="primary"
|
||||
href={`${NEXT_PUBLIC_BASE_URL}/auth/login?callbackUrl=${
|
||||
NEXT_PUBLIC_BASE_URL + location.pathname + location.search
|
||||
}`}>
|
||||
href={`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
@ -8,6 +8,7 @@ import * as huddle01video from "./huddle01video";
|
|||
import * as jitsivideo from "./jitsivideo";
|
||||
import * as office365calendar from "./office365calendar";
|
||||
import * as office365video from "./office365video";
|
||||
import * as slackmessaging from "./slackmessaging";
|
||||
import * as stripepayment from "./stripepayment";
|
||||
import * as tandemvideo from "./tandemvideo";
|
||||
import * as zoomvideo from "./zoomvideo";
|
||||
|
@ -23,6 +24,7 @@ const appStore = {
|
|||
jitsivideo,
|
||||
office365calendar,
|
||||
office365video,
|
||||
slackmessaging,
|
||||
stripepayment,
|
||||
tandemvideo,
|
||||
zoomvideo,
|
||||
|
|
37
packages/app-store/slackmessaging/api/add.ts
Normal file
37
packages/app-store/slackmessaging/api/add.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const client_id = process.env.SLACK_CLIENT_ID;
|
||||
const scopes = ["commands", "users:read", "users:read.email"];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
// Get user
|
||||
await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
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=";
|
||||
res.status(200).json({ url });
|
||||
}
|
||||
res.status(404).json({ error: "Not Found" });
|
||||
}
|
51
packages/app-store/slackmessaging/api/callback.ts
Normal file
51
packages/app-store/slackmessaging/api/callback.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const client_id = process.env.SLACK_CLIENT_ID;
|
||||
const client_secret = process.env.SLACK_CLIENT_SECRET;
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
// Get user
|
||||
const { code } = req.query;
|
||||
console.log(req.query);
|
||||
|
||||
if (!code) {
|
||||
res.redirect("/apps/installed"); // Redirect to where the user was if they cancel the signup or if the oauth fails
|
||||
}
|
||||
|
||||
const query = {
|
||||
client_secret,
|
||||
client_id,
|
||||
code,
|
||||
};
|
||||
const params = stringify(query);
|
||||
console.log("params", params);
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
res.redirect("/apps/installed");
|
||||
}
|
||||
}
|
28
packages/app-store/slackmessaging/api/commandHandler.ts
Normal file
28
packages/app-store/slackmessaging/api/commandHandler.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { showCreateEventMessage, showTodayMessage } from "../lib";
|
||||
import showLinksMessage from "../lib/showLinksMessage";
|
||||
|
||||
export enum SlackAppCommands {
|
||||
CREATE_EVENT = "create-event",
|
||||
TODAY = "today",
|
||||
LINKS = "links",
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
const command = req.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` });
|
||||
}
|
||||
}
|
||||
res.status(400).json({ message: "Invalid request" });
|
||||
}
|
4
packages/app-store/slackmessaging/api/index.ts
Normal file
4
packages/app-store/slackmessaging/api/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
export { default as commandHandler } from "./commandHandler";
|
||||
export { default as interactiveHandler } from "./interactiveHandler";
|
23
packages/app-store/slackmessaging/api/interactiveHandler.ts
Normal file
23
packages/app-store/slackmessaging/api/interactiveHandler.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
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;
|
||||
|
||||
// I've not found a case where actions is ever > than 1 when this function is called.
|
||||
switch (actions) {
|
||||
case InteractionEvents.CREATE_EVENT:
|
||||
return await createEvent(req, res);
|
||||
default:
|
||||
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
|
||||
}
|
||||
}
|
||||
res.status(200).end(); // Send 200 if we dont have a case for the action_id
|
||||
}
|
|
@ -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("slack_messaging");
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.render({
|
||||
onClick() {
|
||||
mutation.mutate("");
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
1
packages/app-store/slackmessaging/components/index.ts
Normal file
1
packages/app-store/slackmessaging/components/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as InstallAppButton } from "./InstallAppButton";
|
29
packages/app-store/slackmessaging/index.ts
Normal file
29
packages/app-store/slackmessaging/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { App } from "@calcom/types/App";
|
||||
|
||||
import _package from "./package.json";
|
||||
|
||||
export const metadata = {
|
||||
name: "Slack App",
|
||||
description: _package.description,
|
||||
installed: !!(
|
||||
process.env.SLACK_CLIENT_ID &&
|
||||
process.env.SLACK_CLIENT_SECRET &&
|
||||
process.env.SLACK_SIGNING_SECRET
|
||||
),
|
||||
category: "messaging",
|
||||
imageSrc: "/apps/slack.svg",
|
||||
logo: "/apps/slack.svg",
|
||||
publisher: "Cal.com",
|
||||
rating: 5,
|
||||
reviews: 69,
|
||||
slug: "slack",
|
||||
title: "Slack App",
|
||||
trending: true,
|
||||
type: "slack_messaging",
|
||||
url: "https://slack.com/",
|
||||
variant: "conferencing",
|
||||
verified: true,
|
||||
email: "help@cal.com",
|
||||
} as App;
|
||||
|
||||
export * as api from "./api";
|
|
@ -0,0 +1,9 @@
|
|||
export const WhereCredsEqualsId = (userId: string) => ({
|
||||
where: {
|
||||
type: "slack_app",
|
||||
key: {
|
||||
path: ["authed_user", "id"],
|
||||
equals: userId,
|
||||
},
|
||||
},
|
||||
});
|
142
packages/app-store/slackmessaging/lib/actions/createEvent.ts
Normal file
142
packages/app-store/slackmessaging/lib/actions/createEvent.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { WebClient } from "@slack/web-api";
|
||||
import dayjs from "dayjs";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import db from "@calcom/prisma";
|
||||
|
||||
import { WhereCredsEqualsId } from "../WhereCredsEqualsID";
|
||||
import { getUserEmail } from "../utils";
|
||||
import BookingSuccess from "../views/BookingSuccess";
|
||||
|
||||
// TODO: Move this type to a shared location - being used in more than one package.
|
||||
export type BookingCreateBody = {
|
||||
email: string;
|
||||
end: string;
|
||||
web3Details?: {
|
||||
userWallet: string;
|
||||
userSignature: unknown;
|
||||
};
|
||||
eventTypeId: number;
|
||||
guests?: string[];
|
||||
location: string;
|
||||
name: string;
|
||||
notes?: string;
|
||||
rescheduleUid?: string;
|
||||
start: string;
|
||||
timeZone: string;
|
||||
user?: string | string[];
|
||||
language: string;
|
||||
customInputs: { label: string; value: string }[];
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function createEvent(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {
|
||||
user,
|
||||
view: {
|
||||
state: { values },
|
||||
},
|
||||
} = 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
|
||||
.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
...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 slackCredentials = foundUser?.credentials[0].key; // Only one slack credential for user
|
||||
|
||||
// @ts-ignore access_token must exist on slackCredentials otherwise we have wouldnt have reached this endpoint
|
||||
|
||||
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(
|
||||
async (userId: string) => await 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: "inPerson", // TODO: Make this pickable in the future - defaulting to in person as any video provider that does not exist within the monorepo will crash the app.
|
||||
timeZone: foundUser?.timeZone ?? "",
|
||||
language: foundUser?.locale ?? "en",
|
||||
customInputs: [{ label: "", value: "" }],
|
||||
metadata: {},
|
||||
notes: "This event was created with slack.",
|
||||
};
|
||||
|
||||
fetch(`${WEBAPP_URL}/api/book/event`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(PostData),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
return res.status(200).send(""); // Slack requires a 200 to be sent to clear the modal. This makes it massive pain to update the user that the event has been created.
|
||||
})
|
||||
.catch(() => {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ text: "Event creation failed. Please try again", response_action: "update" });
|
||||
});
|
||||
}
|
3
packages/app-store/slackmessaging/lib/index.ts
Normal file
3
packages/app-store/slackmessaging/lib/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as showCreateEventMessage } from "./showCreateEventMessage";
|
||||
export { default as showTodayMessage } from "./showTodayMessage";
|
||||
export * as utils from "./utils";
|
|
@ -0,0 +1,39 @@
|
|||
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 { CreateEventModal, NoUserMessage } from "./views";
|
||||
|
||||
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
|
||||
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),
|
||||
});
|
||||
res.status(200).end();
|
||||
}
|
48
packages/app-store/slackmessaging/lib/showLinksMessage.ts
Normal file
48
packages/app-store/slackmessaging/lib/showLinksMessage.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { KnownBlock, WebClient } from "@slack/web-api";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
|
||||
import { CreateEventModal, NoUserMessage } from "./views";
|
||||
import ShowLinks from "./views/ShowLinks";
|
||||
|
||||
export default async function showLinksMessage(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
|
||||
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`,
|
||||
//@ts-ignore this doesnt need to be of type Block[] - an object works completely fine
|
||||
blocks,
|
||||
});
|
||||
|
||||
res.status(200).end();
|
||||
}
|
54
packages/app-store/slackmessaging/lib/showTodayMessage.ts
Normal file
54
packages/app-store/slackmessaging/lib/showTodayMessage.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
|
||||
import { NoUserMessage, TodayMessage } from "./views";
|
||||
|
||||
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
|
||||
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 } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(TodayMessage(bookings));
|
||||
}
|
8
packages/app-store/slackmessaging/lib/utils.ts
Normal file
8
packages/app-store/slackmessaging/lib/utils.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
const getUserEmail = async (client: WebClient, userId: string) =>
|
||||
await (
|
||||
await client.users.info({ user: userId })
|
||||
).user?.profile?.email;
|
||||
|
||||
export { getUserEmail };
|
|
@ -0,0 +1,9 @@
|
|||
import { Blocks, Message } from "slack-block-builder";
|
||||
|
||||
const BookingSuccess = () => {
|
||||
return Message()
|
||||
.blocks(Blocks.Section({ text: `Your booking has been created!` }))
|
||||
.buildToObject();
|
||||
};
|
||||
|
||||
export default BookingSuccess;
|
|
@ -0,0 +1,53 @@
|
|||
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: boolean = 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;
|
20
packages/app-store/slackmessaging/lib/views/NoUser.ts
Normal file
20
packages/app-store/slackmessaging/lib/views/NoUser.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
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;
|
31
packages/app-store/slackmessaging/lib/views/ShowLinks.ts
Normal file
31
packages/app-store/slackmessaging/lib/views/ShowLinks.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import dayjs from "dayjs";
|
||||
import { Blocks, Elements, Message } from "slack-block-builder";
|
||||
|
||||
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}/${links.slug}`,
|
||||
}).accessory(Elements.Button({ text: "Open", url: `${WEBAPP_URL}/${links.slug}` }))
|
||||
)
|
||||
)
|
||||
.buildToJSON();
|
||||
};
|
||||
|
||||
export default ShowLinks;
|
27
packages/app-store/slackmessaging/lib/views/TodayMessage.ts
Normal file
27
packages/app-store/slackmessaging/lib/views/TodayMessage.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Booking } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { Modal, Blocks, Elements, Bits, Message } from "slack-block-builder";
|
||||
|
||||
import { BASE_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()
|
||||
.buildToJSON();
|
||||
}
|
||||
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: `${BASE_URL}/cancel/${booking.uid}` }))
|
||||
)
|
||||
)
|
||||
.buildToObject();
|
||||
};
|
||||
|
||||
export default TodayMessage;
|
3
packages/app-store/slackmessaging/lib/views/index.ts
Normal file
3
packages/app-store/slackmessaging/lib/views/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as CreateEventModal } from "./CreateEventModal";
|
||||
export { default as TodayMessage } from "./TodayMessage";
|
||||
export { default as NoUserMessage } from "./NoUser";
|
16
packages/app-store/slackmessaging/package.json
Normal file
16
packages/app-store/slackmessaging/package.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/slackmessaging",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"description": "This is a package for the intergration of slack into the app-store",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@slack/web-api": "^6.7.0",
|
||||
"slack-block-builder": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
}
|
||||
}
|
31
packages/app-store/slackmessaging/static/icon.svg
Normal file
31
packages/app-store/slackmessaging/static/icon.svg
Normal file
|
@ -0,0 +1,31 @@
|
|||
<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>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -8,6 +8,7 @@ datasource db {
|
|||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["filterJson"]
|
||||
}
|
||||
|
||||
generator zod {
|
||||
|
|
8
packages/types/App.d.ts
vendored
8
packages/types/App.d.ts
vendored
|
@ -13,7 +13,13 @@ export interface App {
|
|||
* */
|
||||
installed: boolean;
|
||||
/** The app type */
|
||||
type: `${string}_calendar` | `${string}_payment` | `${string}_video` | `${string}_web3` | `${string}_other`;
|
||||
type:
|
||||
| `${string}_calendar`
|
||||
| `${string}_messaging`
|
||||
| `${string}_payment`
|
||||
| `${string}_video`
|
||||
| `${string}_web3`
|
||||
| `${string}_other`;
|
||||
/** The display name for the app, TODO settle between this or name */
|
||||
title: string;
|
||||
/** The display name for the app */
|
||||
|
|
Loading…
Reference in a new issue