first commit
This commit is contained in:
21
calcom/apps/ai/.env.example
Normal file
21
calcom/apps/ai/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
BACKEND_URL=http://localhost:3002/api
|
||||
# BACKEND_URL=https://api.cal.com/v1
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
# FRONTEND_URL=https://cal.com
|
||||
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
# This is for the onboard route. Which domain should we send emails from?
|
||||
SENDER_DOMAIN=cal.ai
|
||||
|
||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||
PARSE_KEY=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optionally trace completions at https://smith.langchain.com
|
||||
# LANGCHAIN_TRACING_V2=true
|
||||
# LANGCHAIN_ENDPOINT=
|
||||
# LANGCHAIN_API_KEY=
|
||||
# LANGCHAIN_PROJECT=
|
||||
68
calcom/apps/ai/README.md
Normal file
68
calcom/apps/ai/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Cal.ai
|
||||
|
||||
Welcome to [Cal.ai](https://cal.ai)!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "clear my afternoon"
|
||||
- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
|
||||
|
||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||
|
||||
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||
|
||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
|
||||
|
||||
## Recognition
|
||||
|
||||
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||
|
||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need:
|
||||
|
||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `me@dev.example.com`)
|
||||
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
### Agent Architecture
|
||||
|
||||
The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
|
||||
|
||||
Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
|
||||
|
||||
Here is the full architecture:
|
||||
|
||||

|
||||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, you can use either [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client), an open source tunnelling tool; or [nGrok](https://ngrok.com/), a popular closed source tunnelling tool.
|
||||
|
||||
For Tunnelmole, run `tmole 3005` (or the AI app's port number) in a new terminal. Please replace `3005` with the port number if it is different. In the output, you'll see two URLs, one http and a https (we recommend using the https url for privacy and security). To install Tunnelmole, use `curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install`. (On Windows, download [tmole.exe](https://tunnelmole.com/downloads/tmole.exe))
|
||||
|
||||
For nGrok, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install nGrok first.
|
||||
|
||||
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
|
||||
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
|
||||
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`. Set the priority to `10` if prompted.
|
||||
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
|
||||
5. In the Destination URL field, use the Tunnelmole or ngrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.tunnelmole.net/api/receive?parseKey=ABC-123` or `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
|
||||
6. Activate "POST the raw, full MIME message".
|
||||
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the Tunnelmole or ngrok listener and server.
|
||||
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
Please feel free to improve any part of this architecture!
|
||||
5
calcom/apps/ai/next-env.d.ts
vendored
Normal file
5
calcom/apps/ai/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
24
calcom/apps/ai/next.config.js
Normal file
24
calcom/apps/ai/next.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "https://cal.com/ai",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
28
calcom/apps/ai/package.json
Normal file
28
calcom/apps/ai/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@langchain/core": "^0.1.26",
|
||||
"@langchain/openai": "^0.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.1.17",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.5.4",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev -p 3005",
|
||||
"format": "npx prettier . --write",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
55
calcom/apps/ai/src/app/api/agent/route.ts
Normal file
55
calcom/apps/ai/src/app/api/agent/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import agent from "../../../utils/agent";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
// Allow agent loop to run for up to 5 minutes
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Launches a LangChain agent to process an incoming email,
|
||||
* then sends the response to the user.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const json = await request.json();
|
||||
|
||||
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
|
||||
|
||||
if ((!message && !subject) || !user) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
|
||||
|
||||
// Send response to user
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: agentEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
|
||||
to: user.email,
|
||||
from: agentEmail,
|
||||
});
|
||||
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
44
calcom/apps/ai/src/app/api/onboard/route.ts
Normal file
44
calcom/apps/ai/src/app/api/onboard/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const { userId } = await request.json();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("User not found", { status: 404 });
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
subject: "Welcome to Cal AI",
|
||||
to: user.email,
|
||||
from: `${user.username}@${env.SENDER_DOMAIN}`,
|
||||
text: `Hi ${
|
||||
user.name || `@${user.username}`
|
||||
},\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
|
||||
user.username
|
||||
}@${
|
||||
env.SENDER_DOMAIN
|
||||
}.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
|
||||
html: `Hi ${
|
||||
user.name || `@${user.username}`
|
||||
},<br><br>I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.<br><br>Here are some things you can ask me:<br><br>- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)<br>- "What meetings do I have today?" (I'll show you your schedule)<br>- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)<br><br>I'm still learning, so if you have any feedback, please send it to <a href="https://twitter.com/calcom">@calcom</a> on X!<br><br>Remember, you can always reach me here, at ${
|
||||
user.username
|
||||
}@${env.SENDER_DOMAIN}.<br><br>Looking forward to working together (:<br><br>- Cal AI`,
|
||||
});
|
||||
return new Response("OK", { status: 200 });
|
||||
};
|
||||
186
calcom/apps/ai/src/app/api/receive/route.ts
Normal file
186
calcom/apps/ai/src/app/api/receive/route.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { ParsedMail, Source } from "mailparser";
|
||||
import { simpleParser } from "mailparser";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import { extractUsers } from "../../../utils/extractUsers";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
// Allow receive loop to run for up to 30 seconds
|
||||
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* Verifies email signature and app authorization,
|
||||
* then hands off to booking agent.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
const subject = body.subject || "";
|
||||
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: `ai:email:${envelope.from}`,
|
||||
rateLimitingType: "ai",
|
||||
});
|
||||
} catch (error) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
|
||||
}
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||
|
||||
// User has not installed the app from the app store. Direct them to install it.
|
||||
if (!(credential as { apiKey: string })?.apiKey) {
|
||||
const url = env.APP_URL;
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability, users] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
fetchAvailability({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
extractUsers(`${parsed.text} ${parsed.subject}`),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(availability.error);
|
||||
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(eventTypes.error);
|
||||
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
const { workingHours } = availability;
|
||||
|
||||
const appHost = getHostFromHeaders(request.headers);
|
||||
|
||||
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text || "",
|
||||
subject: parsed.subject || "",
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
username: user.username,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
users,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
return new NextResponse("ok");
|
||||
};
|
||||
47
calcom/apps/ai/src/env.mjs
Normal file
47
calcom/apps/ai/src/env.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
FRONTEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
SENDER_DOMAIN: z.string().min(1),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
OPENAI_API_KEY: z.string().min(1),
|
||||
SENDGRID_API_KEY: z.string().min(1),
|
||||
DATABASE_URL: z.string().url(),
|
||||
},
|
||||
});
|
||||
BIN
calcom/apps/ai/src/public/architecture.png
Normal file
BIN
calcom/apps/ai/src/public/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
121
calcom/apps/ai/src/tools/createBooking.ts
Normal file
121
calcom/apps/ai/src/tools/createBooking.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UserList } from "~/src/types/user";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Creates a booking for a user by event type, times, and timezone.
|
||||
*/
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
users,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
invite,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
users: UserList;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
invite: number;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const user = users.find((u) => u.id === invite);
|
||||
|
||||
if (!user) {
|
||||
return { error: `User with id ${invite} not found to invite` };
|
||||
}
|
||||
|
||||
const responses = {
|
||||
id: invite.toString(),
|
||||
name: user.username,
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
metadata: {},
|
||||
responses,
|
||||
start,
|
||||
timeZone,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
}
|
||||
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Creates a booking on the primary user's calendar.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
users,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
invite,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBooking",
|
||||
schema: z.object({
|
||||
end: z
|
||||
.string()
|
||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||
eventTypeId: z.number(),
|
||||
language: z.string(),
|
||||
invite: z.number().describe("External user id to invite."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default createBookingTool;
|
||||
66
calcom/apps/ai/src/tools/deleteBooking.ts
Normal file
66
calcom/apps/ai/src/tools/deleteBooking.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Cancels a booking for a user by ID with reason.
|
||||
*/
|
||||
const cancelBooking = async ({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
}: {
|
||||
apiKey: string;
|
||||
id: string;
|
||||
reason: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ reason }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking cancelled";
|
||||
};
|
||||
|
||||
const cancelBookingTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Cancel a booking",
|
||||
func: async ({ id, reason }) => {
|
||||
return JSON.stringify(
|
||||
await cancelBooking({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "cancelBooking",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default cancelBookingTool;
|
||||
77
calcom/apps/ai/src/tools/getAvailability.ts
Normal file
77
calcom/apps/ai/src/tools/getAvailability.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Availability } from "../types/availability";
|
||||
|
||||
/**
|
||||
* Fetches availability for a user by date range and event type.
|
||||
*/
|
||||
export const fetchAvailability = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}): Promise<Partial<Availability> | { error: string }> => {
|
||||
const params: { [k: string]: string } = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return {
|
||||
busy: data.busy,
|
||||
dateRanges: data.dateRanges,
|
||||
timeZone: data.timeZone,
|
||||
workingHours: data.workingHours,
|
||||
};
|
||||
};
|
||||
|
||||
const getAvailabilityTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get availability of users within range.",
|
||||
func: async ({ userIds, dateFrom, dateTo }) => {
|
||||
return JSON.stringify(
|
||||
await Promise.all(
|
||||
userIds.map(
|
||||
async (userId) =>
|
||||
await fetchAvailability({
|
||||
userId: userId,
|
||||
apiKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
userIds: z.array(z.number()).describe("The users to fetch availability for."),
|
||||
dateFrom: z.string(),
|
||||
dateTo: z.string(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getAvailabilityTool;
|
||||
75
calcom/apps/ai/src/tools/getBookings.ts
Normal file
75
calcom/apps/ai/src/tools/getBookings.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Booking } from "../types/booking";
|
||||
import { BOOKING_STATUS } from "../types/booking";
|
||||
|
||||
/**
|
||||
* Fetches bookings for a user by date range.
|
||||
*/
|
||||
const fetchBookings = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<Booking[] | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
dateFrom: from,
|
||||
dateTo: to,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
const bookings: Booking[] = data.bookings
|
||||
.filter((booking: Booking) => {
|
||||
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||
|
||||
return notCancelled;
|
||||
})
|
||||
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||
endTime,
|
||||
eventTypeId,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
}));
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get bookings for the primary user between two dates.",
|
||||
func: async ({ from, to }) => {
|
||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||
},
|
||||
name: "getBookings",
|
||||
schema: z.object({
|
||||
from: z.string().describe("ISO 8601 datetime string"),
|
||||
to: z.string().describe("ISO 8601 datetime string"),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getBookingsTool;
|
||||
59
calcom/apps/ai/src/tools/getEventTypes.ts
Normal file
59
calcom/apps/ai/src/tools/getEventTypes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { EventType } from "../types/eventType";
|
||||
|
||||
/**
|
||||
* Fetches event types by user ID.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
|
||||
const params: Record<string, string> = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params["userId"] = userId.toString();
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return data.event_types.map((eventType: EventType) => ({
|
||||
id: eventType.id,
|
||||
slug: eventType.slug,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
};
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async ({ userId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getEventTypes",
|
||||
schema: z.object({
|
||||
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getEventTypesTool;
|
||||
124
calcom/apps/ai/src/tools/sendBookingEmail.ts
Normal file
124
calcom/apps/ai/src/tools/sendBookingEmail.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/src/env.mjs";
|
||||
import type { User, UserList } from "~/src/types/user";
|
||||
import sendEmail from "~/src/utils/sendEmail";
|
||||
|
||||
export const sendBookingEmail = async ({
|
||||
user,
|
||||
agentEmail,
|
||||
subject,
|
||||
to,
|
||||
message,
|
||||
eventTypeSlug,
|
||||
slots,
|
||||
date,
|
||||
}: {
|
||||
apiKey: string;
|
||||
user: User;
|
||||
users: UserList;
|
||||
agentEmail: string;
|
||||
subject: string;
|
||||
to: string;
|
||||
message: string;
|
||||
eventTypeSlug: string;
|
||||
slots?: {
|
||||
time: string;
|
||||
text: string;
|
||||
}[];
|
||||
date: {
|
||||
date: string;
|
||||
text: string;
|
||||
};
|
||||
}) => {
|
||||
// const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
|
||||
const timeUrls = slots?.map(({ time, text }) => {
|
||||
return {
|
||||
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
|
||||
text,
|
||||
};
|
||||
});
|
||||
|
||||
const dateUrl = {
|
||||
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
|
||||
text: date.text,
|
||||
};
|
||||
|
||||
await sendEmail({
|
||||
subject,
|
||||
to,
|
||||
cc: user.email,
|
||||
from: agentEmail,
|
||||
text: message
|
||||
.split("[[[Slots]]]")
|
||||
.join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
|
||||
.split("[[[Link]]]")
|
||||
.join(`${dateUrl.text}: ${dateUrl.url}`),
|
||||
html: message
|
||||
.split("\n")
|
||||
.join("<br>")
|
||||
.split("[[[Slots]]]")
|
||||
.join(timeUrls?.map(({ url, text }) => `<a href="${url}">${text}</a>`).join("<br>"))
|
||||
.split("[[[Link]]]")
|
||||
.join(`<a href="${dateUrl.url}">${dateUrl.text}</a>`),
|
||||
});
|
||||
|
||||
return "Booking link sent";
|
||||
};
|
||||
|
||||
const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description:
|
||||
"Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
|
||||
func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
|
||||
return JSON.stringify(
|
||||
await sendBookingEmail({
|
||||
apiKey,
|
||||
user,
|
||||
users,
|
||||
agentEmail,
|
||||
subject,
|
||||
to,
|
||||
message,
|
||||
eventTypeSlug,
|
||||
slots,
|
||||
date,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "sendBookingEmail",
|
||||
|
||||
schema: z.object({
|
||||
message: z
|
||||
.string()
|
||||
.describe(
|
||||
"A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
|
||||
),
|
||||
subject: z.string(),
|
||||
to: z
|
||||
.string()
|
||||
.describe("email address to send the booking link to. Primary user is automatically CC'd"),
|
||||
eventTypeSlug: z.string().describe("the slug of the event type to book"),
|
||||
slots: z
|
||||
.array(
|
||||
z.object({
|
||||
time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
|
||||
text: z.string().describe("minimum readable label. Ex. 4pm."),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.describe("Time slots the external user can click"),
|
||||
date: z
|
||||
.object({
|
||||
date: z.string().describe("YYYY-MM-DD"),
|
||||
text: z.string().describe('"See all times" or similar'),
|
||||
})
|
||||
.describe(
|
||||
"A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default sendBookingEmailTool;
|
||||
85
calcom/apps/ai/src/tools/updateBooking.ts
Normal file
85
calcom/apps/ai/src/tools/updateBooking.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { DynamicStructuredTool } from "@langchain/core/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||
*/
|
||||
const editBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
id,
|
||||
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||
endTime, // Same here: it says end but it's endTime.
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
id: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking edited";
|
||||
};
|
||||
|
||||
const editBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Edit a booking",
|
||||
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||
return JSON.stringify(
|
||||
await editBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
description,
|
||||
endTime,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "editBooking",
|
||||
schema: z.object({
|
||||
description: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
id: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default editBookingTool;
|
||||
25
calcom/apps/ai/src/types/availability.ts
Normal file
25
calcom/apps/ai/src/types/availability.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Availability = {
|
||||
busy: {
|
||||
start: string;
|
||||
end: string;
|
||||
title?: string;
|
||||
}[];
|
||||
timeZone: string;
|
||||
dateRanges: {
|
||||
start: string;
|
||||
end: string;
|
||||
}[];
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
}[];
|
||||
dateOverrides: {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
};
|
||||
currentSeats: number;
|
||||
};
|
||||
23
calcom/apps/ai/src/types/booking.ts
Normal file
23
calcom/apps/ai/src/types/booking.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export enum BOOKING_STATUS {
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
export type Booking = {
|
||||
id: number;
|
||||
userId: number;
|
||||
description: string | null;
|
||||
eventTypeId: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||
metadata: object | null;
|
||||
status: BOOKING_STATUS;
|
||||
responses: { email: string; name: string; location: string } | null;
|
||||
};
|
||||
13
calcom/apps/ai/src/types/eventType.ts
Normal file
13
calcom/apps/ai/src/types/eventType.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type EventType = {
|
||||
id: number;
|
||||
title: string;
|
||||
length: number;
|
||||
metadata: object;
|
||||
slug: string;
|
||||
hosts: {
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
}[];
|
||||
hidden: boolean;
|
||||
// ...
|
||||
};
|
||||
18
calcom/apps/ai/src/types/user.ts
Normal file
18
calcom/apps/ai/src/types/user.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { EventType } from "./eventType";
|
||||
import type { WorkingHours } from "./workingHours";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
workingHours: WorkingHours[];
|
||||
};
|
||||
|
||||
export type UserList = {
|
||||
id?: number;
|
||||
email?: string;
|
||||
username?: string;
|
||||
type: "fromUsername" | "fromEmail";
|
||||
}[];
|
||||
5
calcom/apps/ai/src/types/workingHours.ts
Normal file
5
calcom/apps/ai/src/types/workingHours.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type WorkingHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
122
calcom/apps/ai/src/utils/agent.ts
Normal file
122
calcom/apps/ai/src/utils/agent.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { AgentExecutor, createOpenAIFunctionsAgent } from "langchain/agents";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBooking";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import sendBookingEmail from "../tools/sendBookingEmail";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User, UserList } from "../types/user";
|
||||
import type { WorkingHours } from "../types/workingHours";
|
||||
import now from "./now";
|
||||
|
||||
const gptModel = "gpt-4-0125-preview";
|
||||
|
||||
/**
|
||||
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
|
||||
* Uses a toolchain to book meetings, list available slots, etc.
|
||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||
*/
|
||||
const agent = async (
|
||||
input: string,
|
||||
user: User,
|
||||
users: UserList,
|
||||
apiKey: string,
|
||||
userId: number,
|
||||
agentEmail: string
|
||||
) => {
|
||||
const tools = [
|
||||
// getEventTypes(apiKey),
|
||||
getAvailability(apiKey),
|
||||
getBookings(apiKey, userId),
|
||||
createBookingIfAvailable(apiKey, userId, users),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
sendBookingEmail(apiKey, user, users, agentEmail),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
modelName: gptModel,
|
||||
openAIApiKey: env.OPENAI_API_KEY,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the agent executor with arguments.
|
||||
*/
|
||||
const prompt =
|
||||
ChatPromptTemplate.fromTemplate(`You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
|
||||
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
|
||||
|
||||
The primary user's id is: ${userId}
|
||||
The primary user's username is: ${user.username}
|
||||
The current time in the primary user's timezone is: ${now(user.timeZone, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
The primary user's time zone is: ${user.timeZone}
|
||||
The primary user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||
.join("\n")}
|
||||
The primary user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime};`
|
||||
)
|
||||
.join("\n")}
|
||||
${
|
||||
users.length
|
||||
? `The email references the following @usernames and emails: ${users
|
||||
.map(
|
||||
(u) =>
|
||||
`${
|
||||
(u.id ? `, id: ${u.id}` : "id: (non user)") +
|
||||
(u.username
|
||||
? u.type === "fromUsername"
|
||||
? `, username: @${u.username}`
|
||||
: ", username: REDACTED"
|
||||
: ", (no username)") +
|
||||
(u.email
|
||||
? u.type === "fromEmail"
|
||||
? `, email: ${u.email}`
|
||||
: ", email: REDACTED"
|
||||
: ", (no email)")
|
||||
};`
|
||||
)
|
||||
.join("\n")}`
|
||||
: ""
|
||||
}`);
|
||||
const agent = await createOpenAIFunctionsAgent({
|
||||
llm: model,
|
||||
prompt,
|
||||
tools,
|
||||
});
|
||||
|
||||
const executor = new AgentExecutor({
|
||||
agent,
|
||||
tools,
|
||||
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||
verbose: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const result = await executor.invoke({ input });
|
||||
const { output } = result;
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export default agent;
|
||||
1
calcom/apps/ai/src/utils/context.ts
Normal file
1
calcom/apps/ai/src/utils/context.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const context = { apiKey: "", userId: "" };
|
||||
85
calcom/apps/ai/src/utils/extractUsers.ts
Normal file
85
calcom/apps/ai/src/utils/extractUsers.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { UserList } from "../types/user";
|
||||
|
||||
/*
|
||||
* Extracts usernames (@Example) and emails (hi@example.com) from a string
|
||||
*/
|
||||
export const extractUsers = async (text: string) => {
|
||||
const usernames = text
|
||||
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
|
||||
?.map((username) => username.slice(1).toLowerCase());
|
||||
const emails = text
|
||||
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
|
||||
?.map((email) => email.toLowerCase());
|
||||
|
||||
const dbUsersFromUsernames = usernames
|
||||
? await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
where: {
|
||||
username: {
|
||||
in: usernames,
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const usersFromUsernames = usernames
|
||||
? usernames.map((username) => {
|
||||
const user = dbUsersFromUsernames.find((u) => u.username === username);
|
||||
return user
|
||||
? {
|
||||
username,
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
type: "fromUsername",
|
||||
}
|
||||
: {
|
||||
username,
|
||||
id: null,
|
||||
email: null,
|
||||
type: "fromUsername",
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const dbUsersFromEmails = emails
|
||||
? await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
where: {
|
||||
email: {
|
||||
in: emails,
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const usersFromEmails = emails
|
||||
? emails.map((email) => {
|
||||
const user = dbUsersFromEmails.find((u) => u.email === email);
|
||||
return user
|
||||
? {
|
||||
email,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
type: "fromEmail",
|
||||
}
|
||||
: {
|
||||
email,
|
||||
id: null,
|
||||
username: null,
|
||||
type: "fromEmail",
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return [...usersFromUsernames, ...usersFromEmails] as UserList;
|
||||
};
|
||||
7
calcom/apps/ai/src/utils/host.ts
Normal file
7
calcom/apps/ai/src/utils/host.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||
return `https://${headers.get("host")}`;
|
||||
};
|
||||
|
||||
export default getHostFromHeaders;
|
||||
6
calcom/apps/ai/src/utils/now.ts
Normal file
6
calcom/apps/ai/src/utils/now.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default function now(timeZone: string, options: Intl.DateTimeFormatOptions = {}) {
|
||||
return new Date().toLocaleString("en-US", {
|
||||
timeZone,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
43
calcom/apps/ai/src/utils/sendEmail.ts
Normal file
43
calcom/apps/ai/src/utils/sendEmail.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import mail from "@sendgrid/mail";
|
||||
|
||||
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||
|
||||
/**
|
||||
* Simply send an email by address, subject, and body.
|
||||
*/
|
||||
const send = async ({
|
||||
subject,
|
||||
to,
|
||||
cc,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string | string[];
|
||||
cc?: string | string[];
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}): Promise<boolean> => {
|
||||
mail.setApiKey(sendgridAPIKey);
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
cc,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal.ai",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
|
||||
const res = await mail.send(msg);
|
||||
const success = !!res;
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
export default send;
|
||||
13
calcom/apps/ai/src/utils/verifyParseKey.ts
Normal file
13
calcom/apps/ai/src/utils/verifyParseKey.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Verifies that the request contains the correct parse key.
|
||||
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||
*/
|
||||
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||
|
||||
return verified;
|
||||
};
|
||||
24
calcom/apps/ai/tsconfig.json
Normal file
24
calcom/apps/ai/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"../../packages/types/window.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
18
calcom/apps/api/index.js
Normal file
18
calcom/apps/api/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const http = require("http");
|
||||
const connect = require("connect");
|
||||
const { createProxyMiddleware } = require("http-proxy-middleware");
|
||||
|
||||
const apiProxyV1 = createProxyMiddleware({
|
||||
target: "http://localhost:3003",
|
||||
});
|
||||
|
||||
const apiProxyV2 = createProxyMiddleware({
|
||||
target: "http://localhost:3004",
|
||||
});
|
||||
|
||||
const app = connect();
|
||||
app.use("/", apiProxyV1);
|
||||
|
||||
app.use("/v2", apiProxyV2);
|
||||
|
||||
http.createServer(app).listen(3002);
|
||||
16
calcom/apps/api/package.json
Normal file
16
calcom/apps/api/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@calcom/api-proxy",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node ./index.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"connect": "^3.7.0",
|
||||
"http": "^0.0.1-security",
|
||||
"http-proxy-middleware": "^2.0.6"
|
||||
}
|
||||
}
|
||||
7
calcom/apps/api/v1/.env.example
Normal file
7
calcom/apps/api/v1/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
API_KEY_PREFIX=cal_
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
|
||||
# Get it in console.cal.com
|
||||
CALCOM_LICENSE_KEY=""
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL=http://localhost:5555
|
||||
81
calcom/apps/api/v1/.gitignore
vendored
Normal file
81
calcom/apps/api/v1/.gitignore
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# .env file
|
||||
.env
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
coverage
|
||||
/test-results/
|
||||
playwright/videos
|
||||
playwright/screenshots
|
||||
playwright/artifacts
|
||||
playwright/results
|
||||
playwright/reports/*
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# Webstorm
|
||||
.idea
|
||||
|
||||
### VisualStudioCode template
|
||||
.vscode/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# Prisma-Zod
|
||||
packages/prisma/zod/*.ts
|
||||
|
||||
# Builds
|
||||
dist
|
||||
|
||||
# Linting
|
||||
lint-results
|
||||
|
||||
# Yarn
|
||||
yarn-error.log
|
||||
|
||||
.turbo
|
||||
.next
|
||||
.husky
|
||||
.vscode
|
||||
.env
|
||||
0
calcom/apps/api/v1/.gitkeep
Normal file
0
calcom/apps/api/v1/.gitkeep
Normal file
5
calcom/apps/api/v1/.prettierignore
Normal file
5
calcom/apps/api/v1/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.next/
|
||||
coverage/
|
||||
node_modules/
|
||||
tests/
|
||||
templates/
|
||||
42
calcom/apps/api/v1/LICENSE
Normal file
42
calcom/apps/api/v1/LICENSE
Normal file
@@ -0,0 +1,42 @@
|
||||
The Cal.com Commercial License (the “Commercial License”)
|
||||
Copyright (c) 2020-present Cal.com, Inc
|
||||
|
||||
With regard to the Cal.com Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Cal.com Subscription Terms available
|
||||
at https://cal.com/terms, or other agreements governing
|
||||
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
|
||||
and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription")
|
||||
for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence,
|
||||
you are free to modify this Software and publish patches to the Software. You agree
|
||||
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Commercial Subscription for the correct number of hosts.
|
||||
Notwithstanding the foregoing, you may copy and modify the Software for development
|
||||
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all such
|
||||
modifications. You are not granted any other rights beyond what is expressly stated herein.
|
||||
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
This Commercial License applies only to the part of this Software that is not distributed under
|
||||
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
|
||||
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
|
||||
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
|
||||
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
|
||||
be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Cal.com Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
225
calcom/apps/api/v1/README.md
Normal file
225
calcom/apps/api/v1/README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
<!-- PROJECT LOGO -->
|
||||
<div align="center">
|
||||
<a href="https://cal.com/docs/enterprise-features/api#api-server-specifications">
|
||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
||||
</a>
|
||||
|
||||
<a href="https://cal.com/docs/enterprise-features/api#api-server-specifications">Read the API docs</a>
|
||||
</div>
|
||||
|
||||
# Commercial Cal.com Public API
|
||||
|
||||
Welcome to the Public API ("/apps/api") of the Cal.com.
|
||||
|
||||
This is the public REST api for cal.com.
|
||||
It exposes CRUD Endpoints of all our most important resources.
|
||||
And it makes it easy for anyone to integrate with Cal.com at the application programming level.
|
||||
|
||||
## Stack
|
||||
|
||||
- NextJS
|
||||
- TypeScript
|
||||
- Prisma
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone the main repo (NOT THIS ONE)
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules -j8 https://github.com/calcom/cal.com.git
|
||||
```
|
||||
|
||||
1. Go to the project folder
|
||||
|
||||
```sh
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
1. Copy `apps/api/.env.example` to `apps/api/.env`
|
||||
|
||||
```sh
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
1. Install packages with yarn
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Start developing
|
||||
|
||||
```sh
|
||||
yarn workspace @calcom/api dev
|
||||
```
|
||||
|
||||
1. Open [http://localhost:3002](http://localhost:3002) with your browser to see the result.
|
||||
|
||||
## API Authentication (API Keys)
|
||||
|
||||
The API requires a valid apiKey query param to be passed:
|
||||
You can generate them at <https://app.cal.com/settings/security>
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
GET https://api.cal.com/v1/users?apiKey={INSERT_YOUR_CAL.COM_API_KEY_HERE}
|
||||
```
|
||||
|
||||
API Keys optionally may have expiry dates, if they are expired they won't work. If you create an apiKey without a userId relation, it won't work either for now as it relies on it to establish the current authenticated user.
|
||||
|
||||
In the future we might add support for header Bearer Auth if we need to or if our customers require it.
|
||||
|
||||
## Middlewares
|
||||
|
||||
We don't use the new NextJS 12 Beta Middlewares, mainly because they run on the edge, and are not able to call prisma from api endpoints. We use instead a very nifty library called next-api-middleware that let's us use a similar approach building our own middlewares and applying them as we see fit.
|
||||
|
||||
- withMiddleware() requires some default middlewares (verifyApiKey, etc...)
|
||||
|
||||
## Next.config.js
|
||||
|
||||
### Redirects
|
||||
|
||||
Since this is an API only project, we don't want to have to type /api/ in all the routes, and so redirect all traffic to api, so a call to `api.cal.com/v1` will resolve to `api.cal.com/api/v1`
|
||||
|
||||
Likewise, v1 is added as param query called version to final /api call so we don't duplicate endpoints in the future for versioning if needed.
|
||||
|
||||
### Transpiling locally shared monorepo modules
|
||||
|
||||
We're calling several packages from monorepo, this need to be transpiled before building since are not available as regular npm packages. That's what withTM does.
|
||||
|
||||
```js
|
||||
"@calcom/app-store",
|
||||
"@calcom/prisma",
|
||||
"@calcom/lib",
|
||||
"@calcom/features",
|
||||
```
|
||||
|
||||
## API Endpoint Validation
|
||||
|
||||
We validate that only the supported methods are accepted at each endpoint, so in
|
||||
|
||||
- **/endpoint**: you can only [GET] (all) and [POST] (create new)
|
||||
- **/endpoint/id**: you can read create and edit [GET, PATCH, DELETE]
|
||||
|
||||
### Zod Validations
|
||||
|
||||
The API uses `zod` library like our main web repo. It validates that either GET query parameters or POST body content's are valid and up to our spec. It gives errors when parsing result's with schemas and failing validation.
|
||||
|
||||
We use it in several ways, but mainly, we first import the auto-generated schema from @calcom/prisma for each model, which lives in `lib/validations/`
|
||||
|
||||
We have some shared validations which several resources require, like baseApiParams which parses apiKey in all requests, or querIdAsString or TransformParseInt which deal with the id's coming from req.query.
|
||||
|
||||
- **[*]BaseBodyParams** that omits any values from the model that are too sensitive or we don't want to pick when creating a new resource like id, userId, etc.. (those are gotten from context or elswhere)
|
||||
|
||||
- **[*]Public** that also omits any values that we don't want to expose when returning the model as a response, which we parse against before returning all resources.
|
||||
|
||||
- **[*]BodyParams** which merges both `[*]BaseBodyParams.merge([*]RequiredParams);`
|
||||
|
||||
### Next Validations
|
||||
|
||||
[Next-Validations Docs](https://next-validations.productsway.com/)
|
||||
[Next-Validations Repo](https://github.com/jellydn/next-validations)
|
||||
We also use this useful helper library that let us wrap our endpoints in a validate HOC that checks the req against our validation schema built out with zod for either query and / or body's requests.
|
||||
|
||||
## Testing with Jest + node-mocks-http
|
||||
|
||||
We aim to provide a fully tested API for our peace of mind, this is accomplished by using jest + node-mocks-http
|
||||
|
||||
## Endpoints matrix
|
||||
|
||||
| resource | get [id] | get all | create | edit | delete |
|
||||
| --------------------- | -------- | ------- | ------ | ---- | ------ |
|
||||
| attendees | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| availabilities | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| booking-references | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| event-references | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| destination-calendars | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| custom-inputs | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| event-types | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| memberships | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| payments | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| schedules | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| selected-calendars | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| teams | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| users | ✅ | 👤[1] | ✅ | ✅ | ✅ |
|
||||
|
||||
## Models from database that are not exposed
|
||||
|
||||
mostly because they're deemed too sensitive can be revisited if needed. Also they are expected to be used via cal's webapp.
|
||||
|
||||
- [ ] Api Keys
|
||||
- [ ] Credentials
|
||||
- [ ] Webhooks
|
||||
- [ ] ResetPasswordRequest
|
||||
- [ ] VerificationToken
|
||||
- [ ] ReminderMail
|
||||
|
||||
## Documentation (OpenAPI)
|
||||
|
||||
You will see that each endpoint has a comment at the top with the annotation `@swagger` with the documentation of the endpoint, **please update it if you change the code!** This is what auto-generates the OpenAPI spec by collecting the YAML in each endpoint and parsing it in /docs alongside the json-schema (auto-generated from prisma package, not added to code but manually for now, need to fix later)
|
||||
|
||||
### @calcom/apps/swagger
|
||||
|
||||
The documentation of the API lives inside the code, and it's auto-generated, the only endpoints that return without a valid apiKey are the homepage, with a JSON message redirecting you to the docs. and the /docs endpoint, which returns the OpenAPI 3.0 JSON Spec. Which SwaggerUi then consumes and generates the docs on.
|
||||
|
||||
## Deployment
|
||||
|
||||
`scripts/vercel-deploy.sh`
|
||||
The API is deployed to vercel.com, it uses a similar deployment script to website or webapp, and requires transpilation of several shared packages that are part of our turborepo ["app-store", "prisma", "lib", "ee"]
|
||||
in order to build and deploy properly.
|
||||
|
||||
## Envirorment variables
|
||||
|
||||
### Required
|
||||
|
||||
DATABASE_URL=DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
|
||||
## Optional
|
||||
|
||||
API*KEY_PREFIX=cal*# This can be changed per envirorment so cal*test* for staging for example.
|
||||
|
||||
> If you're self-hosting under our commercial license, you can use any prefix you want for api keys. either leave the default cal\_ (not providing any envirorment variable) or modify it
|
||||
|
||||
**Ensure that while testing swagger, API project should be run in production mode**
|
||||
We make sure of this by not using next in dev, but next build && next start, if you want hot module reloading and such when developing, please use yarn run next directly on apps/api.
|
||||
|
||||
See <https://github.com/vercel/next.js/blob/canary/packages/next/server/dev/hot-reloader.ts#L79>. Here in dev mode OPTIONS method is hardcoded to return only GET and OPTIONS as allowed method. Running in Production mode would cause this file to be not used. This is hot-reloading logic only.
|
||||
To remove this limitation, we need to ensure that on local endpoints are requested by swagger at /api/v1 and not /v1
|
||||
|
||||
## Hosted api through cal.com
|
||||
|
||||
> _❗ WARNING: This is still experimental and not fully implemented yet❗_
|
||||
|
||||
Go to console.cal.com
|
||||
Add a deployment or go to an existing one.
|
||||
Activate API or Admin addon
|
||||
Provide your `DATABASE_URL`
|
||||
Now you can call api.cal.com?key=CALCOM_LICENSE_KEY, which will connect to your own databaseUrl.
|
||||
|
||||
## How to deploy
|
||||
|
||||
We recommend deploying API in vercel.
|
||||
|
||||
There's some settings that you'll need to setup.
|
||||
|
||||
Under Vercel > Your API Project > Settings
|
||||
|
||||
In General > Build & Development Settings
|
||||
BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps`
|
||||
OUTPUT DIRECTORY: `apps/api/.next`
|
||||
|
||||
In Git > Ignored Build Step
|
||||
|
||||
Add this command: `./scripts/vercel-deploy.sh`
|
||||
|
||||
See `scripts/vercel-deploy.sh` for more info on how the deployment is done.
|
||||
|
||||
> _❗ IMORTANT: If you're forking the API repo you will need to update the URLs in both the main repo [`.gitmodules`](https://github.com/calcom/cal.com/blob/main/.gitmodules#L7) and this repo [`./scripts/vercel-deploy.sh`](https://github.com/calcom/api/blob/main/scripts/vercel-deploy.sh#L3) ❗_
|
||||
|
||||
## Environment variables
|
||||
|
||||
Lastly API requires an env var for `DATABASE_URL` and `CALCOM_LICENSE_KEY`
|
||||
15
calcom/apps/api/v1/instrumentation.ts
Normal file
15
calcom/apps/api/v1/instrumentation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
}
|
||||
1
calcom/apps/api/v1/lib/constants.ts
Normal file
1
calcom/apps/api/v1/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PRISMA_CLIENT_CACHING_TIME = 1000 * 60 * 60 * 24; // one day in ms
|
||||
24
calcom/apps/api/v1/lib/helpers/addRequestid.ts
Normal file
24
calcom/apps/api/v1/lib/helpers/addRequestid.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
export const addRequestId: NextMiddleware = async (_req, res, next) => {
|
||||
// Apply header with unique ID to every request
|
||||
res.setHeader("Calcom-Response-ID", nanoid());
|
||||
// Add all headers here instead of next.config.js as it is throwing error( Cannot set headers after they are sent to the client) for OPTIONS method
|
||||
// It is known to happen only in Dev Mode.
|
||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS, PATCH, DELETE, POST, PUT");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, api_key, Authorization"
|
||||
);
|
||||
|
||||
// Ensure all OPTIONS request are automatically successful. Headers are already set above.
|
||||
if (_req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
// Let remaining middleware and API route execute
|
||||
await next();
|
||||
};
|
||||
20
calcom/apps/api/v1/lib/helpers/captureErrors.ts
Normal file
20
calcom/apps/api/v1/lib/helpers/captureErrors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { captureException as SentryCaptureException } from "@sentry/nextjs";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
export const captureErrors: NextMiddleware = async (_req, res, next) => {
|
||||
try {
|
||||
// Catch any errors that are thrown in remaining
|
||||
// middleware and the API route handler
|
||||
await next();
|
||||
} catch (error) {
|
||||
SentryCaptureException(error);
|
||||
const redactedError = redactError(error);
|
||||
if (redactedError instanceof Error) {
|
||||
res.status(400).json({ message: redactedError.message, error: redactedError });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ message: "Something went wrong", error });
|
||||
}
|
||||
};
|
||||
23
calcom/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts
Normal file
23
calcom/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
const safeGet = async <T = unknown>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
return get<T>(key);
|
||||
} catch (error) {
|
||||
// Don't crash if EDGE_CONFIG env var is missing
|
||||
}
|
||||
};
|
||||
|
||||
export const config = { matcher: "/:path*" };
|
||||
|
||||
export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => {
|
||||
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
|
||||
if (isInMaintenanceMode) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: "API is currently under maintenance. Please try again at a later time." });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
9
calcom/apps/api/v1/lib/helpers/extendRequest.ts
Normal file
9
calcom/apps/api/v1/lib/helpers/extendRequest.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
export const extendRequest: NextMiddleware = async (req, res, next) => {
|
||||
req.pagination = {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
};
|
||||
await next();
|
||||
};
|
||||
32
calcom/apps/api/v1/lib/helpers/httpMethods.ts
Normal file
32
calcom/apps/api/v1/lib/helpers/httpMethods.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
export const httpMethod = (allowedHttpMethod: "GET" | "POST" | "PATCH" | "DELETE"): NextMiddleware => {
|
||||
return async function (req, res, next) {
|
||||
if (req.method === allowedHttpMethod || req.method == "OPTIONS") {
|
||||
await next();
|
||||
} else {
|
||||
res.status(405).json({ message: `Only ${allowedHttpMethod} Method allowed` });
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
};
|
||||
// Made this so we can support several HTTP Methods in one route and use it there.
|
||||
// Could be further extracted into a third function or refactored into one.
|
||||
// that checks if it's just a string or an array and apply the correct logic to both cases.
|
||||
export const httpMethods = (allowedHttpMethod: string[]): NextMiddleware => {
|
||||
return async function (req, res, next) {
|
||||
if (allowedHttpMethod.some((method) => method === req.method || req.method == "OPTIONS")) {
|
||||
await next();
|
||||
} else {
|
||||
res.status(405).json({ message: `Only ${allowedHttpMethod} Method allowed` });
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const HTTP_POST = httpMethod("POST");
|
||||
export const HTTP_GET = httpMethod("GET");
|
||||
export const HTTP_PATCH = httpMethod("PATCH");
|
||||
export const HTTP_DELETE = httpMethod("DELETE");
|
||||
export const HTTP_GET_DELETE_PATCH = httpMethods(["GET", "DELETE", "PATCH"]);
|
||||
export const HTTP_GET_OR_POST = httpMethods(["GET", "POST"]);
|
||||
90
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
Normal file
90
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiResponse, NextApiRequest } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
|
||||
import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("rateLimitApiKey middleware", () => {
|
||||
it("should return 401 if no apiKey is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {},
|
||||
});
|
||||
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(401);
|
||||
expect(res._getJSONData()).toEqual({ message: "No apiKey provided" });
|
||||
});
|
||||
|
||||
it("should call checkRateLimitAndThrowError with correct parameters", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockResolvedValueOnce({
|
||||
limit: 100,
|
||||
remaining: 99,
|
||||
reset: Date.now(),
|
||||
});
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
identifier: "test-key",
|
||||
rateLimitingType: "api",
|
||||
onRateLimiterResponse: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("should set rate limit headers correctly", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
const rateLimiterResponse = {
|
||||
limit: 100,
|
||||
remaining: 99,
|
||||
reset: Date.now(),
|
||||
};
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
});
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
|
||||
expect(res.getHeader("X-RateLimit-Remaining")).toBe(rateLimiterResponse.remaining);
|
||||
expect(res.getHeader("X-RateLimit-Reset")).toBe(rateLimiterResponse.reset);
|
||||
});
|
||||
|
||||
it("should return 429 if rate limit is exceeded", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded"));
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
|
||||
});
|
||||
});
|
||||
24
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.ts
Normal file
24
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
|
||||
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
|
||||
// TODO: Add a way to add trusted api keys
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: req.query.apiKey as string,
|
||||
rateLimitingType: "api",
|
||||
onRateLimiterResponse: (response) => {
|
||||
res.setHeader("X-RateLimit-Limit", response.limit);
|
||||
res.setHeader("X-RateLimit-Remaining", response.remaining);
|
||||
res.setHeader("X-RateLimit-Reset", response.reset);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(429).json({ message: "Rate limit exceeded" });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
14
calcom/apps/api/v1/lib/helpers/safeParseJSON.ts
Normal file
14
calcom/apps/api/v1/lib/helpers/safeParseJSON.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function parseJSONSafely(str: string) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
if ((e as Error).message.includes("Unexpected token")) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid JSON in the body: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
46
calcom/apps/api/v1/lib/helpers/verifyApiKey.ts
Normal file
46
calcom/apps/api/v1/lib/helpers/verifyApiKey.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
const now = new Date();
|
||||
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
// Check if the apiKey query param is provided.
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||
// Hash the key again before matching against the database records.
|
||||
const hashedKey = hashAPIKey(strippedApiKey);
|
||||
// Check if the hashed api key exists in database.
|
||||
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
|
||||
// If cannot find any api key. Throw a 401 Unauthorized.
|
||||
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
|
||||
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
|
||||
return res.status(401).json({ error: "This apiKey is expired" });
|
||||
}
|
||||
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
|
||||
// save the user id in the request for later use
|
||||
req.userId = apiKey.userId;
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
|
||||
req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
export const verifyCredentialSyncEnabled: NextMiddleware = async (req, res, next) => {
|
||||
const { isSystemWideAdmin } = req;
|
||||
|
||||
if (!isSystemWideAdmin) {
|
||||
return res.status(403).json({ error: "Only admin API keys can access credential syncing endpoints" });
|
||||
}
|
||||
|
||||
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
||||
return res.status(501).json({ error: "Credential syncing is not enabled" });
|
||||
}
|
||||
|
||||
if (
|
||||
req.headers[process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"] !==
|
||||
process.env.CALCOM_CREDENTIAL_SYNC_SECRET
|
||||
) {
|
||||
return res.status(401).json({ message: "Invalid credential sync secret" });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
51
calcom/apps/api/v1/lib/helpers/withMiddleware.ts
Normal file
51
calcom/apps/api/v1/lib/helpers/withMiddleware.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { label } from "next-api-middleware";
|
||||
|
||||
import { addRequestId } from "./addRequestid";
|
||||
import { captureErrors } from "./captureErrors";
|
||||
import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode";
|
||||
import { extendRequest } from "./extendRequest";
|
||||
import {
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
HTTP_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
} from "./httpMethods";
|
||||
import { rateLimitApiKey } from "./rateLimitApiKey";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
import { verifyCredentialSyncEnabled } from "./verifyCredentialSyncEnabled";
|
||||
import { withPagination } from "./withPagination";
|
||||
|
||||
const middleware = {
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_PATCH,
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
checkIsInMaintenanceMode,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
captureErrors,
|
||||
verifyCredentialSyncEnabled,
|
||||
};
|
||||
|
||||
type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder = [
|
||||
// The order here, determines the order of execution
|
||||
"checkIsInMaintenanceMode",
|
||||
"extendRequest",
|
||||
"captureErrors",
|
||||
"verifyApiKey",
|
||||
"rateLimitApiKey",
|
||||
"addRequestId",
|
||||
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||
|
||||
const withMiddleware = label(middleware, middlewareOrder);
|
||||
|
||||
export { withMiddleware, middleware, middlewareOrder };
|
||||
17
calcom/apps/api/v1/lib/helpers/withPagination.ts
Normal file
17
calcom/apps/api/v1/lib/helpers/withPagination.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
import z from "zod";
|
||||
|
||||
const withPage = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
take: z.coerce.number().min(1).optional().default(10),
|
||||
});
|
||||
|
||||
export const withPagination: NextMiddleware = async (req, _, next) => {
|
||||
const { page, take } = withPage.parse(req.query);
|
||||
const skip = (page - 1) * take;
|
||||
req.pagination = {
|
||||
take,
|
||||
skip,
|
||||
};
|
||||
await next();
|
||||
};
|
||||
187
calcom/apps/api/v1/lib/types.ts
Normal file
187
calcom/apps/api/v1/lib/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type {
|
||||
Attendee,
|
||||
Availability,
|
||||
Booking,
|
||||
BookingReference,
|
||||
Credential,
|
||||
DestinationCalendar,
|
||||
EventType,
|
||||
EventTypeCustomInput,
|
||||
Membership,
|
||||
Payment,
|
||||
ReminderMail,
|
||||
Schedule,
|
||||
SelectedCalendar,
|
||||
Team,
|
||||
User,
|
||||
Webhook,
|
||||
} from "@calcom/prisma/client";
|
||||
|
||||
// Base response, used for all responses
|
||||
export type BaseResponse = {
|
||||
message?: string;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
// User
|
||||
export type UserResponse = BaseResponse & {
|
||||
user?: Partial<User>;
|
||||
};
|
||||
|
||||
export type UsersResponse = BaseResponse & {
|
||||
users?: Partial<User>[];
|
||||
};
|
||||
|
||||
// Team
|
||||
export type TeamResponse = BaseResponse & {
|
||||
team?: Partial<Team>;
|
||||
owner?: Partial<Membership>;
|
||||
};
|
||||
export type TeamsResponse = BaseResponse & {
|
||||
teams?: Partial<Team>[];
|
||||
};
|
||||
|
||||
// SelectedCalendar
|
||||
export type SelectedCalendarResponse = BaseResponse & {
|
||||
selected_calendar?: Partial<SelectedCalendar>;
|
||||
};
|
||||
export type SelectedCalendarsResponse = BaseResponse & {
|
||||
selected_calendars?: Partial<SelectedCalendar>[];
|
||||
};
|
||||
|
||||
// Attendee
|
||||
export type AttendeeResponse = BaseResponse & {
|
||||
attendee?: Partial<Attendee>;
|
||||
};
|
||||
// Grouping attendees in booking arrays for now,
|
||||
// later might remove endpoint and move to booking endpoint altogether.
|
||||
export type AttendeesResponse = BaseResponse & {
|
||||
attendees?: Partial<Attendee>[];
|
||||
};
|
||||
|
||||
// Availability
|
||||
export type AvailabilityResponse = BaseResponse & {
|
||||
availability?: Partial<Availability>;
|
||||
};
|
||||
export type AvailabilitiesResponse = BaseResponse & {
|
||||
availabilities?: Partial<Availability>[];
|
||||
};
|
||||
|
||||
// BookingReference
|
||||
export type BookingReferenceResponse = BaseResponse & {
|
||||
booking_reference?: Partial<BookingReference>;
|
||||
};
|
||||
export type BookingReferencesResponse = BaseResponse & {
|
||||
booking_references?: Partial<BookingReference>[];
|
||||
};
|
||||
|
||||
// Booking
|
||||
export type BookingResponse = BaseResponse & {
|
||||
booking?: Partial<Booking>;
|
||||
};
|
||||
export type BookingsResponse = BaseResponse & {
|
||||
bookings?: Partial<Booking>[];
|
||||
};
|
||||
|
||||
// Credential
|
||||
export type CredentialResponse = BaseResponse & {
|
||||
credential?: Partial<Credential>;
|
||||
};
|
||||
export type CredentialsResponse = BaseResponse & {
|
||||
credentials?: Partial<Credential>[];
|
||||
};
|
||||
|
||||
// DestinationCalendar
|
||||
export type DestinationCalendarResponse = BaseResponse & {
|
||||
destination_calendar?: Partial<DestinationCalendar>;
|
||||
};
|
||||
export type DestinationCalendarsResponse = BaseResponse & {
|
||||
destination_calendars?: Partial<DestinationCalendar>[];
|
||||
};
|
||||
|
||||
// Membership
|
||||
export type MembershipResponse = BaseResponse & {
|
||||
membership?: Partial<Membership>;
|
||||
};
|
||||
export type MembershipsResponse = BaseResponse & {
|
||||
memberships?: Partial<Membership>[];
|
||||
};
|
||||
|
||||
// EventTypeCustomInput
|
||||
export type EventTypeCustomInputResponse = BaseResponse & {
|
||||
event_type_custom_input?: Partial<EventTypeCustomInput>;
|
||||
};
|
||||
export type EventTypeCustomInputsResponse = BaseResponse & {
|
||||
event_type_custom_inputs?: Partial<EventTypeCustomInput>[];
|
||||
};
|
||||
// From rrule https://jakubroztocil.github.io/rrule freq
|
||||
export enum Frequency {
|
||||
"YEARLY",
|
||||
"MONTHLY",
|
||||
"WEEKLY",
|
||||
"DAILY",
|
||||
"HOURLY",
|
||||
"MINUTELY",
|
||||
"SECONDLY",
|
||||
}
|
||||
interface EventTypeExtended extends Omit<EventType, "recurringEvent" | "locations"> {
|
||||
recurringEvent: {
|
||||
dtstart?: Date | undefined;
|
||||
interval?: number | undefined;
|
||||
count?: number | undefined;
|
||||
freq?: Frequency | undefined;
|
||||
until?: Date | undefined;
|
||||
tzid?: string | undefined;
|
||||
} | null;
|
||||
locations:
|
||||
| {
|
||||
link?: string | undefined;
|
||||
address?: string | undefined;
|
||||
hostPhoneNumber?: string | undefined;
|
||||
type: EventLocationType;
|
||||
}[]
|
||||
| null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| any;
|
||||
}
|
||||
|
||||
// EventType
|
||||
export type EventTypeResponse = BaseResponse & {
|
||||
event_type?: Partial<EventType | EventTypeExtended>;
|
||||
};
|
||||
export type EventTypesResponse = BaseResponse & {
|
||||
event_types?: Partial<EventType | EventTypeExtended>[];
|
||||
};
|
||||
|
||||
// Payment
|
||||
export type PaymentResponse = BaseResponse & {
|
||||
payment?: Partial<Payment>;
|
||||
};
|
||||
export type PaymentsResponse = BaseResponse & {
|
||||
payments?: Partial<Payment>[];
|
||||
};
|
||||
|
||||
// Schedule
|
||||
export type ScheduleResponse = BaseResponse & {
|
||||
schedule?: Partial<Schedule>;
|
||||
};
|
||||
export type SchedulesResponse = BaseResponse & {
|
||||
schedules?: Partial<Schedule>[];
|
||||
};
|
||||
|
||||
// Webhook
|
||||
export type WebhookResponse = BaseResponse & {
|
||||
webhook?: Partial<Webhook> | null;
|
||||
};
|
||||
export type WebhooksResponse = BaseResponse & {
|
||||
webhooks?: Partial<Webhook>[];
|
||||
};
|
||||
|
||||
// ReminderMail
|
||||
export type ReminderMailResponse = BaseResponse & {
|
||||
reminder_mail?: Partial<ReminderMail>;
|
||||
};
|
||||
export type ReminderMailsResponse = BaseResponse & {
|
||||
reminder_mails?: Partial<ReminderMail>[];
|
||||
};
|
||||
14
calcom/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts
Normal file
14
calcom/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||
|
||||
export function extractUserIdsFromQuery({ isSystemWideAdmin, query }: NextApiRequest) {
|
||||
/** Guard: Only admins can query other users */
|
||||
if (!isSystemWideAdmin) {
|
||||
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
||||
}
|
||||
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
|
||||
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
|
||||
}
|
||||
37
calcom/apps/api/v1/lib/utils/isAdmin.ts
Normal file
37
calcom/apps/api/v1/lib/utils/isAdmin.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { ScopeOfAdmin } from "./scopeOfAdmin";
|
||||
|
||||
export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
const { userId } = req;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
|
||||
if (!user) return { isAdmin: false, scope: null };
|
||||
|
||||
const { role: userRole } = user;
|
||||
if (userRole === UserPermissionRole.ADMIN) return { isAdmin: true, scope: ScopeOfAdmin.SystemWide };
|
||||
|
||||
const orgOwnerOrAdminMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
team: {
|
||||
isOrganization: true,
|
||||
},
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!orgOwnerOrAdminMemberships.length) return { isAdmin: false, scope: null };
|
||||
|
||||
return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
|
||||
};
|
||||
4
calcom/apps/api/v1/lib/utils/isValidBase64Image.ts
Normal file
4
calcom/apps/api/v1/lib/utils/isValidBase64Image.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isValidBase64Image(input: string): boolean {
|
||||
const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
return regex.test(input);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
type AccessibleUsersType = {
|
||||
memberUserIds: number[];
|
||||
adminUserId: number;
|
||||
};
|
||||
|
||||
const getAllOrganizationMemberships = async (
|
||||
memberships: {
|
||||
userId: number;
|
||||
role: MembershipRole;
|
||||
teamId: number;
|
||||
}[],
|
||||
orgId: number
|
||||
) => {
|
||||
return memberships.reduce<number[]>((acc, membership) => {
|
||||
if (membership.teamId === orgId) {
|
||||
acc.push(membership.userId);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const getAllAdminMemberships = async (userId: number) => {
|
||||
return await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getAllOrganizationMembers = async (organizationId: number) => {
|
||||
return await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getAccessibleUsers = async ({
|
||||
memberUserIds,
|
||||
adminUserId,
|
||||
}: AccessibleUsersType): Promise<number[]> => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
isOrganization: true,
|
||||
},
|
||||
accepted: true,
|
||||
OR: [
|
||||
{ userId: { in: memberUserIds } },
|
||||
{ userId: adminUserId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const orgId = memberships.find((membership) => membership.userId === adminUserId)?.teamId;
|
||||
if (!orgId) return [];
|
||||
|
||||
const allAccessibleMemberUserIds = await getAllOrganizationMemberships(memberships, orgId);
|
||||
const accessibleUserIds = allAccessibleMemberUserIds.filter((userId) => userId !== adminUserId);
|
||||
return accessibleUserIds;
|
||||
};
|
||||
|
||||
export const retrieveOrgScopedAccessibleUsers = async ({ adminId }: { adminId: number }) => {
|
||||
const adminMemberships = await getAllAdminMemberships(adminId);
|
||||
const organizationId = adminMemberships.find((membership) => membership.team.isOrganization)?.team.id;
|
||||
if (!organizationId) return [];
|
||||
|
||||
const allMemberships = await getAllOrganizationMembers(organizationId);
|
||||
return allMemberships.map((membership) => membership.userId);
|
||||
};
|
||||
4
calcom/apps/api/v1/lib/utils/scopeOfAdmin.ts
Normal file
4
calcom/apps/api/v1/lib/utils/scopeOfAdmin.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const ScopeOfAdmin = {
|
||||
SystemWide: "SystemWide",
|
||||
OrgOwnerOrAdmin: "OrgOwnerOrAdmin",
|
||||
} as const;
|
||||
4
calcom/apps/api/v1/lib/utils/stringifyISODate.ts
Normal file
4
calcom/apps/api/v1/lib/utils/stringifyISODate.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const stringifyISODate = (date: Date | undefined): string => {
|
||||
return `${date?.toISOString()}`;
|
||||
};
|
||||
// TODO: create a function that takes an object and returns a stringified version of dates of it.
|
||||
29
calcom/apps/api/v1/lib/validations/api-key.ts
Normal file
29
calcom/apps/api/v1/lib/validations/api-key.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _ApiKeyModel as ApiKey } from "@calcom/prisma/zod";
|
||||
|
||||
export const apiKeyCreateBodySchema = ApiKey.pick({
|
||||
note: true,
|
||||
expiresAt: true,
|
||||
userId: true,
|
||||
})
|
||||
.partial({ userId: true })
|
||||
.merge(z.object({ neverExpires: z.boolean().optional() }))
|
||||
.strict();
|
||||
|
||||
export const apiKeyEditBodySchema = ApiKey.pick({
|
||||
note: true,
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
export const apiKeyPublicSchema = ApiKey.pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
lastUsedAt: true,
|
||||
/** We might never want to expose these. Leaving this a as reminder. */
|
||||
// hashedKey: true,
|
||||
});
|
||||
39
calcom/apps/api/v1/lib/validations/attendee.ts
Normal file
39
calcom/apps/api/v1/lib/validations/attendee.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _AttendeeModel as Attendee } from "@calcom/prisma/zod";
|
||||
|
||||
import { timeZone } from "~/lib/validations/shared/timeZone";
|
||||
|
||||
export const schemaAttendeeBaseBodyParams = Attendee.pick({
|
||||
bookingId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
});
|
||||
|
||||
const schemaAttendeeCreateParams = z
|
||||
.object({
|
||||
bookingId: z.number().int(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
timeZone: timeZone,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const schemaAttendeeEditParams = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
})
|
||||
.strict();
|
||||
export const schemaAttendeeEditBodyParams = schemaAttendeeBaseBodyParams.merge(schemaAttendeeEditParams);
|
||||
export const schemaAttendeeCreateBodyParams = schemaAttendeeBaseBodyParams.merge(schemaAttendeeCreateParams);
|
||||
|
||||
export const schemaAttendeeReadPublic = Attendee.pick({
|
||||
id: true,
|
||||
bookingId: true,
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
});
|
||||
56
calcom/apps/api/v1/lib/validations/availability.ts
Normal file
56
calcom/apps/api/v1/lib/validations/availability.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _AvailabilityModel as Availability, _ScheduleModel as Schedule } from "@calcom/prisma/zod";
|
||||
import { denullishShape } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const schemaAvailabilityBaseBodyParams = /** We make all these properties required */ denullishShape(
|
||||
Availability.pick({
|
||||
/** We need to pass the schedule where this availability belongs to */
|
||||
scheduleId: true,
|
||||
})
|
||||
);
|
||||
|
||||
export const schemaAvailabilityReadPublic = Availability.pick({
|
||||
id: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
date: true,
|
||||
scheduleId: true,
|
||||
days: true,
|
||||
// eventTypeId: true /** @deprecated */,
|
||||
// userId: true /** @deprecated */,
|
||||
}).merge(z.object({ success: z.boolean().optional(), Schedule: Schedule.partial() }).partial());
|
||||
|
||||
const schemaAvailabilityCreateParams = z
|
||||
.object({
|
||||
startTime: z.date().or(z.string()),
|
||||
endTime: z.date().or(z.string()),
|
||||
days: z.array(z.number()).optional(),
|
||||
date: z.date().or(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const schemaAvailabilityEditParams = z
|
||||
.object({
|
||||
startTime: z.date().or(z.string()).optional(),
|
||||
endTime: z.date().or(z.string()).optional(),
|
||||
days: z.array(z.number()).optional(),
|
||||
date: z.date().or(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaAvailabilityEditBodyParams = schemaAvailabilityEditParams;
|
||||
|
||||
export const schemaAvailabilityCreateBodyParams = schemaAvailabilityBaseBodyParams.merge(
|
||||
schemaAvailabilityCreateParams
|
||||
);
|
||||
|
||||
export const schemaAvailabilityReadBodyParams = z
|
||||
.object({
|
||||
userId: z.union([z.number(), z.array(z.number())]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const schemaSingleAvailabilityReadBodyParams = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
28
calcom/apps/api/v1/lib/validations/booking-reference.ts
Normal file
28
calcom/apps/api/v1/lib/validations/booking-reference.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { _BookingReferenceModel as BookingReference } from "@calcom/prisma/zod";
|
||||
import { denullishShape } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const schemaBookingReferenceBaseBodyParams = BookingReference.pick({
|
||||
type: true,
|
||||
bookingId: true,
|
||||
uid: true,
|
||||
meetingId: true,
|
||||
meetingPassword: true,
|
||||
meetingUrl: true,
|
||||
deleted: true,
|
||||
}).partial();
|
||||
|
||||
export const schemaBookingReferenceReadPublic = BookingReference.pick({
|
||||
id: true,
|
||||
type: true,
|
||||
bookingId: true,
|
||||
uid: true,
|
||||
meetingId: true,
|
||||
meetingPassword: true,
|
||||
meetingUrl: true,
|
||||
deleted: true,
|
||||
});
|
||||
|
||||
export const schemaBookingCreateBodyParams = BookingReference.omit({ id: true, bookingId: true })
|
||||
.merge(denullishShape(BookingReference.pick({ bookingId: true })))
|
||||
.strict();
|
||||
export const schemaBookingEditBodyParams = schemaBookingCreateBodyParams.partial();
|
||||
88
calcom/apps/api/v1/lib/validations/booking.ts
Normal file
88
calcom/apps/api/v1/lib/validations/booking.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _BookingModel as Booking, _AttendeeModel, _UserModel, _PaymentModel } from "@calcom/prisma/zod";
|
||||
import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { schemaQueryUserId } from "./shared/queryUserId";
|
||||
|
||||
const schemaBookingBaseBodyParams = Booking.pick({
|
||||
uid: true,
|
||||
userId: true,
|
||||
eventTypeId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
status: true,
|
||||
}).partial();
|
||||
|
||||
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
|
||||
|
||||
export const schemaBookingGetParams = z.object({
|
||||
dateFrom: iso8601.optional(),
|
||||
dateTo: iso8601.optional(),
|
||||
order: z.enum(["asc", "desc"]).default("asc"),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
|
||||
});
|
||||
|
||||
const schemaBookingEditParams = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
startTime: iso8601.optional(),
|
||||
endTime: iso8601.optional(),
|
||||
// Not supporting responses in edit as that might require re-triggering emails
|
||||
// responses
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams
|
||||
.merge(schemaBookingEditParams)
|
||||
.omit({ uid: true });
|
||||
|
||||
export const schemaBookingReadPublic = Booking.extend({
|
||||
attendees: z
|
||||
.array(
|
||||
_AttendeeModel.pick({
|
||||
email: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
user: _UserModel
|
||||
.pick({
|
||||
email: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
})
|
||||
.nullish(),
|
||||
payment: z
|
||||
.array(
|
||||
_PaymentModel.pick({
|
||||
id: true,
|
||||
success: true,
|
||||
paymentOption: true,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
responses: z.record(z.any()).nullable(),
|
||||
}).pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
description: true,
|
||||
eventTypeId: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
timeZone: true,
|
||||
attendees: true,
|
||||
user: true,
|
||||
payment: true,
|
||||
metadata: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
fromReschedule: true,
|
||||
});
|
||||
18
calcom/apps/api/v1/lib/validations/connected-calendar.ts
Normal file
18
calcom/apps/api/v1/lib/validations/connected-calendar.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const CalendarSchema = z.object({
|
||||
externalId: z.string(),
|
||||
name: z.string(),
|
||||
primary: z.boolean(),
|
||||
readOnly: z.boolean(),
|
||||
});
|
||||
|
||||
const IntegrationSchema = z.object({
|
||||
name: z.string(),
|
||||
appId: z.string(),
|
||||
userId: z.number(),
|
||||
integration: z.string(),
|
||||
calendars: z.array(CalendarSchema),
|
||||
});
|
||||
|
||||
export const schemaConnectedCalendarsReadPublic = z.array(IntegrationSchema);
|
||||
64
calcom/apps/api/v1/lib/validations/credential-sync.ts
Normal file
64
calcom/apps/api/v1/lib/validations/credential-sync.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
const userId = z.string().transform((val) => {
|
||||
const userIdInt = parseInt(val);
|
||||
|
||||
if (isNaN(userIdInt)) {
|
||||
throw new HttpError({ message: "userId is not a valid number", statusCode: 400 });
|
||||
}
|
||||
|
||||
return userIdInt;
|
||||
});
|
||||
const appSlug = z.string();
|
||||
const credentialId = z.string().transform((val) => {
|
||||
const credentialIdInt = parseInt(val);
|
||||
|
||||
if (isNaN(credentialIdInt)) {
|
||||
throw new HttpError({ message: "credentialId is not a valid number", statusCode: 400 });
|
||||
}
|
||||
|
||||
return credentialIdInt;
|
||||
});
|
||||
const encryptedKey = z.string();
|
||||
|
||||
export const schemaCredentialGetParams = z.object({
|
||||
userId,
|
||||
appSlug: appSlug.optional(),
|
||||
});
|
||||
|
||||
export const schemaCredentialPostParams = z.object({
|
||||
userId,
|
||||
createSelectedCalendar: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
return val === "true";
|
||||
}),
|
||||
createDestinationCalendar: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
return val === "true";
|
||||
}),
|
||||
});
|
||||
|
||||
export const schemaCredentialPostBody = z.object({
|
||||
appSlug,
|
||||
encryptedKey,
|
||||
});
|
||||
|
||||
export const schemaCredentialPatchParams = z.object({
|
||||
userId,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
export const schemaCredentialPatchBody = z.object({
|
||||
encryptedKey,
|
||||
});
|
||||
|
||||
export const schemaCredentialDeleteParams = z.object({
|
||||
userId,
|
||||
credentialId,
|
||||
});
|
||||
48
calcom/apps/api/v1/lib/validations/destination-calendar.ts
Normal file
48
calcom/apps/api/v1/lib/validations/destination-calendar.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
||||
|
||||
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
||||
integration: true,
|
||||
externalId: true,
|
||||
eventTypeId: true,
|
||||
bookingId: true,
|
||||
userId: true,
|
||||
}).partial();
|
||||
|
||||
const schemaDestinationCalendarCreateParams = z
|
||||
.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
eventTypeId: z.number().optional(),
|
||||
bookingId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaDestinationCalendarCreateBodyParams = schemaDestinationCalendarBaseBodyParams.merge(
|
||||
schemaDestinationCalendarCreateParams
|
||||
);
|
||||
|
||||
const schemaDestinationCalendarEditParams = z
|
||||
.object({
|
||||
integration: z.string().optional(),
|
||||
externalId: z.string().optional(),
|
||||
eventTypeId: z.number().optional(),
|
||||
bookingId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaDestinationCalendarEditBodyParams = schemaDestinationCalendarBaseBodyParams.merge(
|
||||
schemaDestinationCalendarEditParams
|
||||
);
|
||||
|
||||
export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
|
||||
id: true,
|
||||
integration: true,
|
||||
externalId: true,
|
||||
eventTypeId: true,
|
||||
bookingId: true,
|
||||
userId: true,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { _EventTypeCustomInputModel as EventTypeCustomInput } from "@calcom/prisma/zod";
|
||||
|
||||
export const schemaEventTypeCustomInputBaseBodyParams = EventTypeCustomInput.omit({
|
||||
id: true,
|
||||
});
|
||||
|
||||
export const schemaEventTypeCustomInputPublic = EventTypeCustomInput.omit({});
|
||||
|
||||
export const schemaEventTypeCustomInputBodyParams = schemaEventTypeCustomInputBaseBodyParams.strict();
|
||||
|
||||
export const schemaEventTypeCustomInputEditBodyParams = schemaEventTypeCustomInputBaseBodyParams
|
||||
.partial()
|
||||
.strict();
|
||||
173
calcom/apps/api/v1/lib/validations/event-type.ts
Normal file
173
calcom/apps/api/v1/lib/validations/event-type.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { Frequency } from "~/lib/types";
|
||||
|
||||
import { jsonSchema } from "./shared/jsonSchema";
|
||||
import { schemaQueryUserId } from "./shared/queryUserId";
|
||||
import { timeZone } from "./shared/timeZone";
|
||||
|
||||
const recurringEventInputSchema = z.object({
|
||||
dtstart: z.string().optional(),
|
||||
interval: z.number().int().optional(),
|
||||
count: z.number().int().optional(),
|
||||
freq: z.nativeEnum(Frequency).optional(),
|
||||
until: z.string().optional(),
|
||||
tzid: timeZone.optional(),
|
||||
});
|
||||
|
||||
const hostSchema = _HostModel.pick({
|
||||
isFixed: true,
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const childrenSchema = z.object({
|
||||
id: z.number().int(),
|
||||
userId: z.number().int(),
|
||||
});
|
||||
|
||||
export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
title: true,
|
||||
description: true,
|
||||
slug: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
position: true,
|
||||
eventName: true,
|
||||
timeZone: true,
|
||||
schedulingType: true,
|
||||
// START Limit future bookings
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
// END Limit future bookings
|
||||
requiresConfirmation: true,
|
||||
disableGuests: true,
|
||||
hideCalendarNotes: true,
|
||||
minimumBookingNotice: true,
|
||||
parentId: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
teamId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
slotInterval: true,
|
||||
successRedirectUrl: true,
|
||||
locations: true,
|
||||
bookingLimits: true,
|
||||
onlyShowFirstAvailableSlot: true,
|
||||
durationLimits: true,
|
||||
assignAllTeamMembers: true,
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
children: z.array(childrenSchema).optional().default([]),
|
||||
hosts: z.array(hostSchema).optional().default([]),
|
||||
})
|
||||
)
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
const schemaEventTypeCreateParams = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
description: z.string().optional().nullable(),
|
||||
length: z.number().int(),
|
||||
metadata: z.any().optional(),
|
||||
recurringEvent: recurringEventInputSchema.optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
parentId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaEventTypeCreateBodyParams = schemaEventTypeBaseBodyParams
|
||||
.merge(schemaEventTypeCreateParams)
|
||||
.merge(schemaQueryUserId.partial());
|
||||
|
||||
const schemaEventTypeEditParams = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.transform((s) => slugify(s))
|
||||
.optional(),
|
||||
length: z.number().int().optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaEventTypeEditBodyParams = schemaEventTypeBaseBodyParams.merge(schemaEventTypeEditParams);
|
||||
export const schemaEventTypeReadPublic = EventType.pick({
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
position: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
scheduleId: true,
|
||||
eventName: true,
|
||||
timeZone: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
requiresConfirmation: true,
|
||||
recurringEvent: true,
|
||||
disableGuests: true,
|
||||
hideCalendarNotes: true,
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
schedulingType: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
slotInterval: true,
|
||||
parentId: true,
|
||||
successRedirectUrl: true,
|
||||
description: true,
|
||||
locations: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
bookingFields: true,
|
||||
bookingLimits: true,
|
||||
onlyShowFirstAvailableSlot: true,
|
||||
durationLimits: true,
|
||||
}).merge(
|
||||
z.object({
|
||||
children: z.array(childrenSchema).optional().default([]),
|
||||
hosts: z.array(hostSchema).optional().default([]),
|
||||
locations: z
|
||||
.array(
|
||||
z.object({
|
||||
link: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
hostPhoneNumber: z.string().optional(),
|
||||
type: z.any().optional(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
metadata: jsonSchema.nullable(),
|
||||
customInputs: customInputSchema.array().optional(),
|
||||
link: z.string().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional().nullable(),
|
||||
})
|
||||
);
|
||||
68
calcom/apps/api/v1/lib/validations/membership.ts
Normal file
68
calcom/apps/api/v1/lib/validations/membership.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { _MembershipModel as Membership, _TeamModel } from "@calcom/prisma/zod";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
export const schemaMembershipBaseBodyParams = Membership.omit({});
|
||||
|
||||
const schemaMembershipRequiredParams = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||
.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
})
|
||||
.transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
|
||||
export const membershipEditBodySchema = Membership.omit({
|
||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||
teamId: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
})
|
||||
.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaMembershipBodyParams = schemaMembershipBaseBodyParams.merge(
|
||||
schemaMembershipRequiredParams
|
||||
);
|
||||
|
||||
export const schemaMembershipPublic = Membership.merge(z.object({ team: _TeamModel }).partial());
|
||||
|
||||
/** We extract userId and teamId from compound ID string */
|
||||
export const membershipIdSchema = schemaQueryIdAsString
|
||||
// So we can query additional team data in memberships
|
||||
.merge(z.object({ teamId: z.union([stringOrNumber, z.array(stringOrNumber)]) }).partial())
|
||||
.transform((v, ctx) => {
|
||||
const [userIdStr, teamIdStr] = v.id.split("_");
|
||||
const userIdInt = schemaQueryIdParseInt.safeParse({ id: userIdStr });
|
||||
const teamIdInt = schemaQueryIdParseInt.safeParse({ id: teamIdStr });
|
||||
if (!userIdInt.success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "userId is not a number" });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (!teamIdInt.success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "teamId is not a number " });
|
||||
return z.NEVER;
|
||||
}
|
||||
return {
|
||||
userId: userIdInt.data.id,
|
||||
teamId: teamIdInt.data.id,
|
||||
};
|
||||
});
|
||||
5
calcom/apps/api/v1/lib/validations/payment.ts
Normal file
5
calcom/apps/api/v1/lib/validations/payment.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { _PaymentModel as Payment } from "@calcom/prisma/zod";
|
||||
|
||||
// FIXME: Payment seems a delicate endpoint, do we need to remove anything here?
|
||||
export const schemaPaymentBodyParams = Payment.omit({ id: true });
|
||||
export const schemaPaymentPublic = Payment.omit({ externalId: true });
|
||||
17
calcom/apps/api/v1/lib/validations/reminder-mail.ts
Normal file
17
calcom/apps/api/v1/lib/validations/reminder-mail.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _ReminderMailModel as ReminderMail } from "@calcom/prisma/zod";
|
||||
|
||||
export const schemaReminderMailBaseBodyParams = ReminderMail.omit({ id: true }).partial();
|
||||
|
||||
export const schemaReminderMailPublic = ReminderMail.omit({});
|
||||
|
||||
const schemaReminderMailRequiredParams = z.object({
|
||||
referenceId: z.number().int(),
|
||||
reminderType: z.enum(["PENDING_BOOKING_CONFIRMATION"]),
|
||||
elapsedMinutes: z.number().int(),
|
||||
});
|
||||
|
||||
export const schemaReminderMailBodyParams = schemaReminderMailBaseBodyParams.merge(
|
||||
schemaReminderMailRequiredParams
|
||||
);
|
||||
43
calcom/apps/api/v1/lib/validations/schedule.ts
Normal file
43
calcom/apps/api/v1/lib/validations/schedule.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { _ScheduleModel as Schedule, _AvailabilityModel as Availability } from "@calcom/prisma/zod";
|
||||
|
||||
import { timeZone } from "./shared/timeZone";
|
||||
|
||||
const schemaScheduleBaseBodyParams = Schedule.omit({ id: true, timeZone: true }).partial();
|
||||
|
||||
export const schemaSingleScheduleBodyParams = schemaScheduleBaseBodyParams.merge(
|
||||
z.object({ userId: z.number().optional(), timeZone: timeZone.optional() })
|
||||
);
|
||||
|
||||
export const schemaCreateScheduleBodyParams = schemaScheduleBaseBodyParams.merge(
|
||||
z.object({ userId: z.number().optional(), name: z.string(), timeZone })
|
||||
);
|
||||
|
||||
export const schemaSchedulePublic = z
|
||||
.object({ id: z.number() })
|
||||
.merge(Schedule)
|
||||
.merge(
|
||||
z.object({
|
||||
availability: z
|
||||
.array(
|
||||
Availability.pick({
|
||||
id: true,
|
||||
eventTypeId: true,
|
||||
date: true,
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
})
|
||||
)
|
||||
.transform((v) =>
|
||||
v.map((item) => ({
|
||||
...item,
|
||||
startTime: dayjs.utc(item.startTime).format("HH:mm:ss"),
|
||||
endTime: dayjs.utc(item.endTime).format("HH:mm:ss"),
|
||||
}))
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
);
|
||||
48
calcom/apps/api/v1/lib/validations/selected-calendar.ts
Normal file
48
calcom/apps/api/v1/lib/validations/selected-calendar.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import z from "zod";
|
||||
|
||||
import { _SelectedCalendarModel as SelectedCalendar } from "@calcom/prisma/zod";
|
||||
|
||||
import { schemaQueryIdAsString } from "./shared/queryIdString";
|
||||
import { schemaQueryIdParseInt } from "./shared/queryIdTransformParseInt";
|
||||
|
||||
export const schemaSelectedCalendarBaseBodyParams = SelectedCalendar;
|
||||
|
||||
export const schemaSelectedCalendarPublic = SelectedCalendar.omit({});
|
||||
|
||||
export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams.partial({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams.partial();
|
||||
|
||||
export const selectedCalendarIdSchema = schemaQueryIdAsString.transform((v, ctx) => {
|
||||
/** We can assume the first part is the userId since it's an integer */
|
||||
const [userIdStr, ...rest] = v.id.split("_");
|
||||
/** We can assume that the remainder is both the integration type and external id combined */
|
||||
const integration_externalId = rest.join("_");
|
||||
/**
|
||||
* Since we only handle calendars here we can split by `_calendar_` and re add it later on.
|
||||
* This handle special cases like `google_calendar_c_blabla@group.calendar.google.com` and
|
||||
* `hubspot_other_calendar`.
|
||||
**/
|
||||
const [_integration, externalId] = integration_externalId.split("_calendar_");
|
||||
const userIdInt = schemaQueryIdParseInt.safeParse({ id: userIdStr });
|
||||
if (!userIdInt.success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "userId is not a number" });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (!_integration) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Missing integration" });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (!externalId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Missing externalId" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return {
|
||||
userId: userIdInt.data.id,
|
||||
/** We re-add the split `_calendar` string */
|
||||
integration: `${_integration}_calendar`,
|
||||
externalId,
|
||||
};
|
||||
});
|
||||
11
calcom/apps/api/v1/lib/validations/shared/baseApiParams.ts
Normal file
11
calcom/apps/api/v1/lib/validations/shared/baseApiParams.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const baseApiParams = z.object({
|
||||
// since we added apiKey as query param this is required by next-validations helper
|
||||
// for query params to work properly and not fail.
|
||||
apiKey: z.string().optional(),
|
||||
// version required for supporting /v1/ redirect to query in api as *?version=1
|
||||
version: z.string().optional(),
|
||||
});
|
||||
9
calcom/apps/api/v1/lib/validations/shared/jsonSchema.ts
Normal file
9
calcom/apps/api/v1/lib/validations/shared/jsonSchema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Helper schema for JSON fields
|
||||
type Literal = boolean | number | string;
|
||||
type Json = Literal | { [key: string]: Json } | Json[];
|
||||
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
|
||||
export const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
|
||||
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryAttendeeEmail = baseApiParams.extend({
|
||||
attendeeEmail: z.string().email(),
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({
|
||||
attendeeEmail: z.union([z.string().email(), z.array(z.string().email())]).optional(),
|
||||
});
|
||||
|
||||
export const withValidQueryAttendeeEmail = withValidation({
|
||||
schema: schemaQueryAttendeeEmail,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
19
calcom/apps/api/v1/lib/validations/shared/queryIdString.ts
Normal file
19
calcom/apps/api/v1/lib/validations/shared/queryIdString.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
/** Used for UUID style id queries */
|
||||
export const schemaQueryIdAsString = baseApiParams
|
||||
.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const withValidQueryIdString = withValidation({
|
||||
schema: schemaQueryIdAsString,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryIdParseInt = baseApiParams.extend({
|
||||
id: z.coerce.number(),
|
||||
});
|
||||
|
||||
export const withValidQueryIdTransformParseInt = withValidation({
|
||||
schema: schemaQueryIdParseInt,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
|
||||
export const getTranscriptFromRecordingId = schemaQueryIdParseInt.extend({
|
||||
recordingId: z.string(),
|
||||
});
|
||||
7
calcom/apps/api/v1/lib/validations/shared/querySlug.ts
Normal file
7
calcom/apps/api/v1/lib/validations/shared/querySlug.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
export const schemaQuerySlug = baseApiParams.extend({
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
21
calcom/apps/api/v1/lib/validations/shared/queryTeamId.ts
Normal file
21
calcom/apps/api/v1/lib/validations/shared/queryTeamId.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryTeamId = baseApiParams
|
||||
.extend({
|
||||
teamId: z
|
||||
.string()
|
||||
.regex(/^\d+$/)
|
||||
.transform((id) => parseInt(id)),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const withValidQueryTeamId = withValidation({
|
||||
schema: schemaQueryTeamId,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
20
calcom/apps/api/v1/lib/validations/shared/queryUserEmail.ts
Normal file
20
calcom/apps/api/v1/lib/validations/shared/queryUserEmail.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||
email: z.union([z.string().email(), z.array(z.string().email())]),
|
||||
});
|
||||
|
||||
export const withValidQueryUserEmail = withValidation({
|
||||
schema: schemaQueryUserEmail,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
26
calcom/apps/api/v1/lib/validations/shared/queryUserId.ts
Normal file
26
calcom/apps/api/v1/lib/validations/shared/queryUserId.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryUserId = baseApiParams.extend({
|
||||
userId: stringOrNumber,
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleUserIds = z.object({
|
||||
userId: z.union([stringOrNumber, z.array(stringOrNumber)]),
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleTeamIds = z.object({
|
||||
teamId: z.union([stringOrNumber, z.array(stringOrNumber)]),
|
||||
});
|
||||
|
||||
export const withValidQueryUserId = withValidation({
|
||||
schema: schemaQueryUserId,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
7
calcom/apps/api/v1/lib/validations/shared/timeZone.ts
Normal file
7
calcom/apps/api/v1/lib/validations/shared/timeZone.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import tzdata from "tzdata";
|
||||
import { z } from "zod";
|
||||
|
||||
// @note: This is a custom validation that checks if the timezone is valid and exists in the tzdb library
|
||||
export const timeZone = z.string().refine((tz: string) => Object.keys(tzdata.zones).includes(tz), {
|
||||
message: `Expected one of the following: ${Object.keys(tzdata.zones).join(", ")}`,
|
||||
});
|
||||
30
calcom/apps/api/v1/lib/validations/team.ts
Normal file
30
calcom/apps/api/v1/lib/validations/team.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _TeamModel as Team } from "@calcom/prisma/zod";
|
||||
|
||||
export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }).partial({
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
pendingPayment: true,
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
smsLockState: true,
|
||||
});
|
||||
|
||||
const schemaTeamRequiredParams = z.object({
|
||||
name: z.string().max(255),
|
||||
});
|
||||
|
||||
export const schemaTeamBodyParams = schemaTeamBaseBodyParams.merge(schemaTeamRequiredParams).strict();
|
||||
|
||||
export const schemaTeamUpdateBodyParams = schemaTeamBodyParams.partial();
|
||||
|
||||
const schemaOwnerId = z.object({
|
||||
ownerId: z.number().optional(),
|
||||
});
|
||||
|
||||
export const schemaTeamCreateBodyParams = schemaTeamBodyParams.merge(schemaOwnerId).strict();
|
||||
|
||||
export const schemaTeamReadPublic = Team.omit({});
|
||||
|
||||
export const schemaTeamsReadPublic = z.array(schemaTeamReadPublic);
|
||||
179
calcom/apps/api/v1/lib/validations/user.ts
Normal file
179
calcom/apps/api/v1/lib/validations/user.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { _UserModel as User } from "@calcom/prisma/zod";
|
||||
import { iso8601 } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { isValidBase64Image } from "~/lib/utils/isValidBase64Image";
|
||||
import { timeZone } from "~/lib/validations/shared/timeZone";
|
||||
|
||||
// @note: These are the ONLY values allowed as weekStart. So user don't introduce bad data.
|
||||
enum weekdays {
|
||||
MONDAY = "Monday",
|
||||
TUESDAY = "Tuesday",
|
||||
WEDNESDAY = "Wednesday",
|
||||
THURSDAY = "Thursday",
|
||||
FRIDAY = "Friday",
|
||||
SATURDAY = "Saturday",
|
||||
SUNDAY = "Sunday",
|
||||
}
|
||||
|
||||
// @note: extracted from apps/web/next-i18next.config.js, update if new locales.
|
||||
enum locales {
|
||||
EN = "en",
|
||||
FR = "fr",
|
||||
IT = "it",
|
||||
RU = "ru",
|
||||
ES = "es",
|
||||
DE = "de",
|
||||
PT = "pt",
|
||||
RO = "ro",
|
||||
NL = "nl",
|
||||
PT_BR = "pt-BR",
|
||||
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
|
||||
KO = "ko",
|
||||
JA = "ja",
|
||||
PL = "pl",
|
||||
AR = "ar",
|
||||
IW = "iw",
|
||||
ZH_CN = "zh-CN",
|
||||
ZH_TW = "zh-TW",
|
||||
CS = "cs",
|
||||
SR = "sr",
|
||||
SV = "sv",
|
||||
VI = "vi",
|
||||
}
|
||||
enum theme {
|
||||
DARK = "dark",
|
||||
LIGHT = "light",
|
||||
}
|
||||
|
||||
enum timeFormat {
|
||||
TWELVE = 12,
|
||||
TWENTY_FOUR = 24,
|
||||
}
|
||||
|
||||
const usernameSchema = z
|
||||
.string()
|
||||
.transform((v) => v.toLowerCase())
|
||||
// .refine(() => {})
|
||||
.superRefine(async (val, ctx) => {
|
||||
if (val) {
|
||||
const result = await checkUsername(val);
|
||||
if (!result.available) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "already_in_use_error" });
|
||||
if (result.premium) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "premium_username" });
|
||||
}
|
||||
});
|
||||
|
||||
// @note: These are the values that are editable via PATCH method on the user Model
|
||||
export const schemaUserBaseBodyParams = User.pick({
|
||||
name: true,
|
||||
email: true,
|
||||
username: true,
|
||||
bio: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
theme: true,
|
||||
appTheme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
hideBranding: true,
|
||||
timeFormat: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
role: true,
|
||||
// @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI.
|
||||
// avatar: true,
|
||||
}).partial();
|
||||
// @note: partial() is used to allow for the user to edit only the fields they want to edit making all optional,
|
||||
// if want to make any required do it in the schemaRequiredParams
|
||||
|
||||
// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value
|
||||
// for example making weekStart only accept weekdays as input
|
||||
const schemaUserEditParams = z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
username: usernameSchema,
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
appTheme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
defaultScheduleId: z
|
||||
.number()
|
||||
.refine((id: number) => id > 0)
|
||||
.optional()
|
||||
.nullable(),
|
||||
locale: z.nativeEnum(locales).optional().nullable(),
|
||||
avatar: z.string().refine(isValidBase64Image).optional(),
|
||||
});
|
||||
|
||||
// @note: These are the values that are editable via PATCH method on the user Model,
|
||||
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
|
||||
|
||||
const schemaUserCreateParams = z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
username: usernameSchema,
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
appTheme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
defaultScheduleId: z
|
||||
.number()
|
||||
.refine((id: number) => id > 0)
|
||||
.optional()
|
||||
.nullable(),
|
||||
locale: z.nativeEnum(locales).optional(),
|
||||
createdDate: iso8601.optional(),
|
||||
avatar: z.string().refine(isValidBase64Image).optional(),
|
||||
});
|
||||
|
||||
// @note: These are the values that are editable via PATCH method on the user Model,
|
||||
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
|
||||
export const schemaUserEditBodyParams = schemaUserBaseBodyParams
|
||||
.merge(schemaUserEditParams)
|
||||
.omit({})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
export const schemaUserCreateBodyParams = schemaUserBaseBodyParams
|
||||
.merge(schemaUserCreateParams)
|
||||
.omit({})
|
||||
.strict();
|
||||
|
||||
// @note: These are the values that are always returned when reading a user
|
||||
export const schemaUserReadPublic = User.pick({
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
appTheme: true,
|
||||
theme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
timeFormat: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
createdDate: true,
|
||||
verified: true,
|
||||
invitedTo: true,
|
||||
role: true,
|
||||
});
|
||||
|
||||
export const schemaUsersReadPublic = z.array(schemaUserReadPublic);
|
||||
57
calcom/apps/api/v1/lib/validations/webhook.ts
Normal file
57
calcom/apps/api/v1/lib/validations/webhook.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
|
||||
import { _WebhookModel as Webhook } from "@calcom/prisma/zod";
|
||||
|
||||
const schemaWebhookBaseBodyParams = Webhook.pick({
|
||||
userId: true,
|
||||
eventTypeId: true,
|
||||
eventTriggers: true,
|
||||
active: true,
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
});
|
||||
|
||||
export const schemaWebhookCreateParams = z
|
||||
.object({
|
||||
// subscriberUrl: z.string().url(),
|
||||
// eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(),
|
||||
// active: z.boolean(),
|
||||
payloadTemplate: z.string().optional().nullable(),
|
||||
eventTypeId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
secret: z.string().optional().nullable(),
|
||||
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
|
||||
// appId: z.string().optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const schemaWebhookCreateBodyParams = schemaWebhookBaseBodyParams.merge(schemaWebhookCreateParams);
|
||||
|
||||
export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
|
||||
.merge(
|
||||
z.object({
|
||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||
secret: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
export const schemaWebhookReadPublic = Webhook.pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
eventTypeId: true,
|
||||
payloadTemplate: true,
|
||||
eventTriggers: true,
|
||||
// FIXME: We have some invalid urls saved in the DB
|
||||
// subscriberUrl: true,
|
||||
/** @todo: find out how to properly add back and validate those. */
|
||||
// eventType: true,
|
||||
// app: true,
|
||||
appId: true,
|
||||
}).merge(
|
||||
z.object({
|
||||
subscriberUrl: z.string(),
|
||||
})
|
||||
);
|
||||
5
calcom/apps/api/v1/next-env.d.ts
vendored
Normal file
5
calcom/apps/api/v1/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
10
calcom/apps/api/v1/next-i18next.config.js
Normal file
10
calcom/apps/api/v1/next-i18next.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const path = require("path");
|
||||
const i18nConfig = require("@calcom/config/next-i18next.config");
|
||||
|
||||
/** @type {import("next-i18next").UserConfig} */
|
||||
const config = {
|
||||
...i18nConfig,
|
||||
localePath: path.resolve("../../web/public/static/locales"),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
103
calcom/apps/api/v1/next.config.js
Normal file
103
calcom/apps/api/v1/next.config.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/core",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/emails",
|
||||
"@calcom/features",
|
||||
"@calcom/lib",
|
||||
"@calcom/prisma",
|
||||
"@calcom/trpc",
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/docs",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Credentials",
|
||||
value: "true",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "*",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET, OPTIONS, PATCH, DELETE, POST, PUT",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value:
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, api_key, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return {
|
||||
afterFiles: [
|
||||
// This redirects requests recieved at / the root to the /api/ folder.
|
||||
{
|
||||
source: "/v:version/:rest*",
|
||||
destination: "/api/v:version/:rest*",
|
||||
},
|
||||
{
|
||||
source: "/api/v2",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`,
|
||||
},
|
||||
{
|
||||
source: "/api/v2/health",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`,
|
||||
},
|
||||
{
|
||||
source: "/api/v2/docs/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/docs/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/api/v2/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/api/v2/:path*`,
|
||||
},
|
||||
// This redirects requests to api/v*/ to /api/ passing version as a query parameter.
|
||||
{
|
||||
source: "/api/v:version/:rest*",
|
||||
destination: "/api/:rest*?version=:version",
|
||||
},
|
||||
// Keeps backwards compatibility with old webhook URLs
|
||||
{
|
||||
source: "/api/hooks/:rest*",
|
||||
destination: "/api/webhooks/:rest*",
|
||||
},
|
||||
],
|
||||
fallback: [
|
||||
// These rewrites are checked after both pages/public files
|
||||
// and dynamic routes are checked
|
||||
{
|
||||
source: "/:path*",
|
||||
destination: `/api/:path*`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
plugins.push((nextConfig) =>
|
||||
withSentryConfig(nextConfig, {
|
||||
autoInstrumentServerFunctions: true,
|
||||
hideSourceMaps: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
18
calcom/apps/api/v1/next.d.ts
vendored
Normal file
18
calcom/apps/api/v1/next.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
|
||||
|
||||
export type * from "next/types";
|
||||
|
||||
export declare module "next" {
|
||||
interface NextApiRequest extends BaseNextApiRequest {
|
||||
session?: Session | null;
|
||||
|
||||
userId: number;
|
||||
method: string;
|
||||
// session: { user: { id: number } };
|
||||
// query: Partial<{ [key: string]: string | string[] }>;
|
||||
isSystemWideAdmin: boolean;
|
||||
isOrganizationOwnerOrAdmin: boolean;
|
||||
pagination: { take: number; skip: number };
|
||||
}
|
||||
}
|
||||
46
calcom/apps/api/v1/package.json
Normal file
46
calcom/apps/api/v1/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@calcom/api",
|
||||
"version": "1.0.0",
|
||||
"description": "Public API for BLS cal",
|
||||
"main": "index.ts",
|
||||
"repository": "git@github.com:calcom/api.git",
|
||||
"author": "BLS media",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=3003 next dev",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "PORT=3003 next start",
|
||||
"docker-start-api": "PORT=80 next start",
|
||||
"type-check": "tsc --pretty --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/types": "*",
|
||||
"node-mocks-http": "^1.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/core": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/emails": "*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.4",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^4.9.4",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
|
||||
|
||||
export async function authMiddleware(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
const { id } = schemaQueryIdAsString.parse(req.query);
|
||||
// Admin can check any api key
|
||||
if (isSystemWideAdmin) return;
|
||||
// Check if user can access the api key
|
||||
const apiKey = await prisma.apiKey.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
if (!apiKey) throw new HttpError({ statusCode: 404, message: "API key not found" });
|
||||
}
|
||||
15
calcom/apps/api/v1/pages/api/api-keys/[id]/_delete.ts
Normal file
15
calcom/apps/api/v1/pages/api/api-keys/[id]/_delete.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
|
||||
|
||||
async function deleteHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdAsString.parse(query);
|
||||
await prisma.apiKey.delete({ where: { id } });
|
||||
return { message: `ApiKey with id: ${id} deleted` };
|
||||
}
|
||||
|
||||
export default defaultResponder(deleteHandler);
|
||||
16
calcom/apps/api/v1/pages/api/api-keys/[id]/_get.ts
Normal file
16
calcom/apps/api/v1/pages/api/api-keys/[id]/_get.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { apiKeyPublicSchema } from "~/lib/validations/api-key";
|
||||
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
|
||||
|
||||
async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdAsString.parse(query);
|
||||
const api_key = await prisma.apiKey.findUniqueOrThrow({ where: { id } });
|
||||
return { api_key: apiKeyPublicSchema.parse(api_key) };
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
17
calcom/apps/api/v1/pages/api/api-keys/[id]/_patch.ts
Normal file
17
calcom/apps/api/v1/pages/api/api-keys/[id]/_patch.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { apiKeyEditBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key";
|
||||
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
|
||||
|
||||
async function patchHandler(req: NextApiRequest) {
|
||||
const { body } = req;
|
||||
const { id } = schemaQueryIdAsString.parse(req.query);
|
||||
const data = apiKeyEditBodySchema.parse(body);
|
||||
const api_key = await prisma.apiKey.update({ where: { id }, data });
|
||||
return { api_key: apiKeyPublicSchema.parse(api_key) };
|
||||
}
|
||||
|
||||
export default defaultResponder(patchHandler);
|
||||
18
calcom/apps/api/v1/pages/api/api-keys/[id]/index.ts
Normal file
18
calcom/apps/api/v1/pages/api/api-keys/[id]/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
|
||||
import { authMiddleware } from "./_auth-middleware";
|
||||
|
||||
export default withMiddleware()(
|
||||
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await authMiddleware(req);
|
||||
return defaultHandler({
|
||||
GET: import("./_get"),
|
||||
PATCH: import("./_patch"),
|
||||
DELETE: import("./_delete"),
|
||||
})(req, res);
|
||||
})
|
||||
);
|
||||
41
calcom/apps/api/v1/pages/api/api-keys/_get.ts
Normal file
41
calcom/apps/api/v1/pages/api/api-keys/_get.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import { apiKeyPublicSchema } from "~/lib/validations/api-key";
|
||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & {
|
||||
args?: Prisma.ApiKeyFindManyArgs;
|
||||
};
|
||||
|
||||
/** Admins can query other users' API keys */
|
||||
function handleAdminRequests(req: CustomNextApiRequest) {
|
||||
// To match type safety with runtime
|
||||
if (!hasReqArgs(req)) throw Error("Missing req.args");
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
if (isSystemWideAdmin && req.query.userId) {
|
||||
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
|
||||
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
|
||||
req.args.where = { userId: { in: userIds } };
|
||||
if (Array.isArray(query.userId)) req.args.orderBy = { userId: "asc" };
|
||||
}
|
||||
}
|
||||
|
||||
function hasReqArgs(req: CustomNextApiRequest): req is Ensure<CustomNextApiRequest, "args"> {
|
||||
return "args" in req;
|
||||
}
|
||||
|
||||
async function getHandler(req: CustomNextApiRequest) {
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
req.args = isSystemWideAdmin ? {} : { where: { userId } };
|
||||
// Proof of concept: allowing mutation in exchange of composability
|
||||
handleAdminRequests(req);
|
||||
const data = await prisma.apiKey.findMany(req.args);
|
||||
return { api_keys: data.map((v) => apiKeyPublicSchema.parse(v)) };
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
46
calcom/apps/api/v1/pages/api/api-keys/_post.ts
Normal file
46
calcom/apps/api/v1/pages/api/api-keys/_post.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
import { generateUniqueAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { apiKeyCreateBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key";
|
||||
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
const { neverExpires, userId: bodyUserId, ...input } = apiKeyCreateBodySchema.parse(req.body);
|
||||
const [hashedKey, apiKey] = generateUniqueAPIKey();
|
||||
const args: Prisma.ApiKeyCreateArgs = {
|
||||
data: {
|
||||
id: v4(),
|
||||
userId,
|
||||
...input,
|
||||
// And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input
|
||||
expiresAt: neverExpires ? null : input.expiresAt,
|
||||
hashedKey,
|
||||
},
|
||||
};
|
||||
|
||||
if (!isSystemWideAdmin && bodyUserId)
|
||||
throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
|
||||
|
||||
if (isSystemWideAdmin && bodyUserId) {
|
||||
const where: Prisma.UserWhereInput = { id: bodyUserId };
|
||||
await prisma.user.findFirstOrThrow({ where });
|
||||
args.data.userId = bodyUserId;
|
||||
}
|
||||
|
||||
const result = await prisma.apiKey.create(args);
|
||||
return {
|
||||
api_key: {
|
||||
...apiKeyPublicSchema.parse(result),
|
||||
key: `${process.env.API_KEY_PREFIX ?? "cal_"}${apiKey}`,
|
||||
},
|
||||
message: "API key created successfully. Save the `key` value as it won't be displayed again.",
|
||||
};
|
||||
}
|
||||
|
||||
export default defaultResponder(postHandler);
|
||||
10
calcom/apps/api/v1/pages/api/api-keys/index.ts
Normal file
10
calcom/apps/api/v1/pages/api/api-keys/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
|
||||
export default withMiddleware("HTTP_GET_OR_POST")(
|
||||
defaultHandler({
|
||||
GET: import("./_get"),
|
||||
POST: import("./_post"),
|
||||
})
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user