Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b2816897 | ||
|
|
00372b6b3b | ||
|
|
6c8fced247 | ||
|
|
52813b01e6 | ||
|
|
77a98d96c8 | ||
|
|
676eac357f | ||
|
|
5bafd1b264 | ||
|
|
0391bfe887 | ||
|
|
c18407d75a | ||
|
|
a631286770 | ||
|
|
dd78741158 | ||
|
|
8f69811e25 | ||
|
|
3a63a8b228 | ||
|
|
ef98105503 | ||
|
|
a18f1eb382 | ||
|
|
2ab6315a8e | ||
|
|
258ebe0420 | ||
|
|
1cfd2f7053 | ||
|
|
a51024e915 | ||
|
|
900e90cd2c | ||
|
|
46ffeaee82 | ||
|
|
22425bb693 | ||
|
|
ad0b8a668c | ||
|
|
bbad2c9d31 | ||
|
|
8b28a6f5df | ||
|
|
93bb37702c | ||
|
|
4a8f251d89 | ||
|
|
cf44919cdd | ||
|
|
024e40b26b | ||
|
|
7c354321be | ||
|
|
fc16ec59d3 | ||
|
|
ab26481738 | ||
|
|
2fbc06314e | ||
|
|
386006fad0 | ||
|
|
ceb555460e | ||
|
|
c28870e029 | ||
|
|
0650898302 | ||
|
|
d004815a68 | ||
|
|
5d76bd6b9c | ||
|
|
d02947f887 | ||
|
|
a3a95a1496 | ||
|
|
d47c6b3fdb | ||
|
|
ebbb1dcce3 | ||
|
|
8fbb1ed426 | ||
|
|
d8adc8eeb1 | ||
|
|
a7bd4aee85 | ||
|
|
82fcda4b42 | ||
|
|
b3b7a09cfb | ||
|
|
60ae817f30 | ||
|
|
74166fc2cb | ||
|
|
fb997045f2 | ||
|
|
73a79cf29b | ||
|
|
54a1663a8b | ||
|
|
883d745784 | ||
|
|
571e93a19a | ||
|
|
d16428da6c | ||
|
|
8c5438663c | ||
|
|
ed76d163cf | ||
|
|
9215725dad | ||
|
|
00131668aa | ||
|
|
0306ad737f | ||
|
|
20dfc34e70 | ||
|
|
b7e8b252d2 | ||
|
|
74f09c332b | ||
|
|
3ce6c23603 | ||
|
|
ad3ee14501 | ||
|
|
1931758fc7 | ||
|
|
fe551af145 | ||
|
|
9fdaaed100 | ||
|
|
c0f8a355e3 | ||
|
|
df95ec4ce8 | ||
|
|
a15cad4d03 | ||
|
|
8904f20eb8 | ||
|
|
0ee81d0739 | ||
|
|
c0bc6f8ccb | ||
|
|
1f1cd1e236 | ||
|
|
dbbd78fb26 | ||
|
|
f6e45842fd | ||
|
|
d25c043046 | ||
|
|
5695ba7c13 | ||
|
|
eeb0322ce1 | ||
|
|
480977c2b5 | ||
|
|
8b2e46a6fa | ||
|
|
842be660b0 | ||
|
|
4f69c0e502 | ||
|
|
be128181e2 | ||
|
|
0aa1628925 | ||
|
|
fd58f669d0 | ||
|
|
51927f82c0 | ||
|
|
0f8a0eaf4a | ||
|
|
b9ac22d4ed | ||
|
|
ebca5c6409 | ||
|
|
6ef35e4dba | ||
|
|
8bb5200dd8 | ||
|
|
4aeb6b7c9b | ||
|
|
ae64d7ba82 | ||
|
|
9dd2dbff19 | ||
|
|
cebbffe230 | ||
|
|
e3e464abfa | ||
|
|
268438b0ff | ||
|
|
355c8de5e7 | ||
|
|
e6056bc4a4 | ||
|
|
f2eebe95bd | ||
|
|
3084013269 | ||
|
|
68568e5a25 | ||
|
|
0eae0bb8fe | ||
|
|
b61820d354 | ||
|
|
1606272850 | ||
|
|
ea04fe1c9b | ||
|
|
4c50b6a8af | ||
|
|
39aa848e9e | ||
|
|
64c56c1b17 | ||
|
|
8e883a6b10 | ||
|
|
9684aa3ad5 | ||
|
|
5fede84bf6 | ||
|
|
a788e07be1 | ||
|
|
68ffd2248b | ||
|
|
4b12d021e4 | ||
|
|
4daf9db9db | ||
|
|
a660b1eb05 | ||
|
|
6f2f098e12 | ||
|
|
6a75ef86b0 | ||
|
|
3d2397879c | ||
|
|
de474089a7 | ||
|
|
8b31b9aa05 | ||
|
|
ad41672958 | ||
|
|
86f2984b30 | ||
|
|
38e62786e4 | ||
|
|
ac3d7c9fae | ||
|
|
98f94ea694 | ||
|
|
8ad80f7ac3 | ||
|
|
296678046c | ||
|
|
fd193e651d | ||
|
|
becad5ee67 | ||
|
|
161dcbdd4c | ||
|
|
7ad6652cb0 | ||
|
|
c467167e91 | ||
|
|
18093824d8 | ||
|
|
8102f3c33c | ||
|
|
902c441248 | ||
|
|
e641bb3d57 | ||
|
|
19d938ed60 | ||
|
|
5561949f9b | ||
|
|
5e98cca062 | ||
|
|
ac858c9bee | ||
|
|
f58f5e4e02 | ||
|
|
ad844f5e51 | ||
|
|
53c1bc45d6 | ||
|
|
0cc93aa87e | ||
|
|
b41b830086 |
@@ -38,6 +38,7 @@ BASECAMP3_USER_AGENT=
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
DAILY_WEBHOOK_SECRET=''
|
||||
DAILY_MEETING_ENDED_WEBHOOK_SECRET=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
|
||||
+6
-1
@@ -240,7 +240,7 @@ CSP_POLICY=
|
||||
# Vercel Edge Config
|
||||
EDGE_CONFIG=
|
||||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD=0 # Override the booker to only load X number of days worth of data
|
||||
|
||||
# Control time intervals on a user's Schedule availability
|
||||
@@ -299,6 +299,7 @@ E2E_TEST_CALCOM_GCAL_KEYS=
|
||||
CALCOM_CREDENTIAL_SYNC_SECRET=""
|
||||
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
|
||||
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret"
|
||||
# This the endpoint from which the token is fetched
|
||||
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
|
||||
# Key should match on Cal.com and your application
|
||||
# must be 24 bytes for AES256 encryption algorithm
|
||||
@@ -347,6 +348,10 @@ SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
# api v2
|
||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||
|
||||
# Tasker features
|
||||
TASKER_ENABLE_WEBHOOKS=0
|
||||
TASKER_ENABLE_EMAILS=0
|
||||
|
||||
# Ratelimiting via unkey
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ env:
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Cron - changeTimeZone
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs "At every full hour." (see https://crontab.guru)
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
cron-scheduleEmailReminders:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/changeTimeZone \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
-sSf
|
||||
@@ -62,6 +62,7 @@ jobs:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
@@ -91,6 +92,7 @@ jobs:
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -49,6 +49,7 @@ jobs:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
@@ -73,6 +74,7 @@ jobs:
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -55,6 +55,7 @@ jobs:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
@@ -80,6 +81,7 @@ jobs:
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -53,6 +53,7 @@ jobs:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
@@ -82,6 +83,7 @@ jobs:
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
@@ -31,6 +32,7 @@ env:
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
@@ -35,6 +36,7 @@ env:
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.cal.com/discord"><img src="https://img.shields.io/badge/Discord-go.cal.com%2Fdiscord-%234A154B" alt="Join Cal.com Discord"></a>
|
||||
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://www.producthunt.com/products/cal-com"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
|
||||
@@ -33,7 +33,7 @@
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://hub.docker.com/r/calcom/cal.com"><img src="https://img.shields.io/docker/pulls/calcom/cal.com"></a>
|
||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -24,6 +25,7 @@ const middleware = {
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
checkIsInMaintenanceMode,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
extendRequest,
|
||||
@@ -36,6 +38,7 @@ type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder = [
|
||||
// The order here, determines the order of execution
|
||||
"checkIsInMaintenanceMode",
|
||||
"extendRequest",
|
||||
"captureErrors",
|
||||
"verifyApiKey",
|
||||
|
||||
@@ -6,6 +6,9 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
pendingPayment: true,
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
smsLockState: true,
|
||||
});
|
||||
|
||||
const schemaTeamRequiredParams = z.object({
|
||||
|
||||
@@ -82,7 +82,6 @@ export const schemaUserBaseBodyParams = User.pick({
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
away: 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,
|
||||
@@ -171,7 +170,6 @@ export const schemaUserReadPublic = User.pick({
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
createdDate: true,
|
||||
verified: true,
|
||||
invitedTo: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
|
||||
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -63,7 +63,7 @@ async function handler(req: NextApiRequest) {
|
||||
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
const key = minimumTokenResponseSchema.parse(decryptedKey);
|
||||
const key = OAuth2UniversalSchema.parse(decryptedKey);
|
||||
|
||||
const credential = await prisma.credential.update({
|
||||
where: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
|
||||
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -70,7 +70,7 @@ async function handler(req: NextApiRequest) {
|
||||
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
const key = minimumTokenResponseSchema.parse(decryptedKey);
|
||||
const key = OAuth2UniversalSchema.parse(decryptedKey);
|
||||
|
||||
// Need to get app type
|
||||
const app = await prisma.app.findUnique({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -10,4 +10,13 @@ JWT_SECRET=
|
||||
SENTRY_DSN=
|
||||
|
||||
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Stripe Billing
|
||||
STRIPE_PRICE_ID_STARTER=
|
||||
STRIPE_PRICE_ID_ESSENTIALS=
|
||||
STRIPE_PRICE_ID_ENTERPRISE=
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
WEB_APP_URL=http://localhost:3000/
|
||||
|
||||
@@ -12,7 +12,7 @@ module.exports = {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: [".eslintrc.js"],
|
||||
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
|
||||
rules: {
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"entryFile": "./apps/api/v2/src/main.js",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"plugins": [
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"start": "nest start",
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch & yarn workspace @calcom/platform-libraries build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && nest start --watch",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/src/main",
|
||||
"start:prod": "SKIP_DOCS_GENERATION=1 node ./dist/apps/api/v2/src/main.js",
|
||||
"test": "yarn dev:build && jest",
|
||||
"test:watch": "yarn dev:build && jest --watch",
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
@@ -37,9 +37,10 @@
|
||||
"@nestjs/passport": "^10.0.2",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.1",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@sentry/node": "^7.86.0",
|
||||
"@sentry/tracing": "^7.86.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
@@ -56,6 +57,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^15.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.11.0",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { AppLoggerMiddleware } from "@/app.logger.middleware";
|
||||
import appConfig from "@/config/app";
|
||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
||||
import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor";
|
||||
import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { EndpointsModule } from "@/modules/endpoints.module";
|
||||
import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@@ -16,29 +26,56 @@ import { AppController } from "./app.controller";
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
// ThrottlerModule.forRootAsync({
|
||||
// imports: [ConfigModule],
|
||||
// inject: [ConfigService],
|
||||
// useFactory: (config: ConfigService<AppConfig>) => ({
|
||||
// throttlers: [
|
||||
// {
|
||||
// name: "short",
|
||||
// ttl: seconds(10),
|
||||
// limit: 3,
|
||||
// },
|
||||
// ],
|
||||
// storage: new ThrottlerStorageRedisService(config.get("db.redisUrl", { infer: true })),
|
||||
// }),
|
||||
// }),
|
||||
RedisModule,
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
throttlers: [
|
||||
{
|
||||
name: "short",
|
||||
ttl: seconds(10),
|
||||
limit: 3,
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
ttl: seconds(30),
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
EndpointsModule,
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
//register prefix for all routes in EndpointsModule
|
||||
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(AppLoggerMiddleware).forRoutes("*");
|
||||
consumer
|
||||
.apply(RawBodyMiddleware)
|
||||
.forRoutes({
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
})
|
||||
.apply(JsonBodyMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RequestIdMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(AppLoggerMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RewriterMiddleware)
|
||||
.forRoutes("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { SentryFilter } from "@/filters/sentry-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import type { ValidationError } from "@nestjs/common";
|
||||
import { BadRequestException, RequestMethod, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { HttpAdapterHost } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import helmet from "helmet";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
|
||||
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
||||
@@ -26,7 +28,7 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: ["Accept", "Authorization", "Content-Type", "Origin"],
|
||||
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
|
||||
maxAge: 86_400,
|
||||
});
|
||||
|
||||
@@ -58,10 +60,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalFilters(new TRPCExceptionFilter());
|
||||
|
||||
app.setGlobalPrefix("api", {
|
||||
exclude: [{ path: "health", method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
return app;
|
||||
|
||||
@@ -14,7 +14,7 @@ const loadConfig = (): AppConfig => {
|
||||
process.env.API_PORT && getEnv("NODE_ENV", "development") === "development"
|
||||
? `:${Number(getEnv("API_PORT", "5555"))}`
|
||||
: ""
|
||||
}/api/v2`,
|
||||
}/v2`,
|
||||
},
|
||||
db: {
|
||||
readUrl: getEnv("DATABASE_READ_URL"),
|
||||
@@ -24,6 +24,13 @@ const loadConfig = (): AppConfig => {
|
||||
next: {
|
||||
authSecret: getEnv("NEXTAUTH_SECRET"),
|
||||
},
|
||||
stripe: {
|
||||
apiKey: getEnv("STRIPE_API_KEY"),
|
||||
webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
|
||||
},
|
||||
app: {
|
||||
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -15,4 +15,11 @@ export type AppConfig = {
|
||||
next: {
|
||||
authSecret: string;
|
||||
};
|
||||
stripe: {
|
||||
apiKey: string;
|
||||
webhookSecret: string;
|
||||
};
|
||||
app: {
|
||||
baseUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TokensModule],
|
||||
providers: [TokensRepository, OAuthFlowService],
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
|
||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
|
||||
controllers: [BookingsController],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("Bookings Endpoints", () => {
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/ee/bookings")
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
@@ -138,7 +138,7 @@ describe("Bookings Endpoints", () => {
|
||||
|
||||
it("should get bookings", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/api/v2/ee/bookings?filters[status]=upcoming")
|
||||
.get("/v2/bookings?filters[status]=upcoming")
|
||||
.then((response) => {
|
||||
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
|
||||
const responseBody: GetBookingsOutput = response.body;
|
||||
@@ -159,7 +159,7 @@ describe("Bookings Endpoints", () => {
|
||||
|
||||
it("should get booking", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/ee/bookings/${createdBooking.uid}`)
|
||||
.get(`/v2/bookings/${createdBooking.uid}`)
|
||||
.then((response) => {
|
||||
const responseBody: GetBookingOutput = response.body;
|
||||
const bookingInfo = responseBody.data;
|
||||
@@ -199,7 +199,7 @@ describe("Bookings Endpoints", () => {
|
||||
// };
|
||||
|
||||
// return request(app.getHttpServer())
|
||||
// .post("/api/v2/ee/bookings/reccuring")
|
||||
// .post("/v2/bookings/recurring")
|
||||
// .send(body)
|
||||
// .expect(201)
|
||||
// .then((response) => {
|
||||
@@ -231,7 +231,7 @@ describe("Bookings Endpoints", () => {
|
||||
// };
|
||||
|
||||
// return request(app.getHttpServer())
|
||||
// .post("/api/v2/ee/bookings/instant")
|
||||
// .post("/v2/bookings/instant")
|
||||
// .send(body)
|
||||
// .expect(201)
|
||||
// .then((response) => {
|
||||
@@ -250,7 +250,7 @@ describe("Bookings Endpoints", () => {
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/api/v2/ee/bookings/${bookingId}/cancel`)
|
||||
.post(`/v2/bookings/${bookingId}/cancel`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input";
|
||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import {
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
Req,
|
||||
InternalServerErrorException,
|
||||
Body,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Get,
|
||||
@@ -27,6 +30,7 @@ import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
getAllUserBookings,
|
||||
@@ -45,18 +49,42 @@ import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-t
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
type BookingRequest = Request & {
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
type OAuthRequestParams = {
|
||||
platformClientId: string;
|
||||
platformRescheduleUrl: string;
|
||||
platformCancelUrl: string;
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PLATFORM_PARAMS = {
|
||||
platformClientId: "",
|
||||
platformCancelUrl: "",
|
||||
platformRescheduleUrl: "",
|
||||
platformBookingUrl: "",
|
||||
arePlatformEmailsEnabled: false,
|
||||
platformBookingLocation: undefined,
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "ee/bookings",
|
||||
path: "/bookings",
|
||||
version: "2",
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings")
|
||||
export class BookingsController {
|
||||
private readonly logger = new Logger("ee bookings controller");
|
||||
private readonly logger = new Logger("BookingsController");
|
||||
|
||||
constructor(
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
private readonly prismaReadService: PrismaReadService
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly billingService: BillingService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -117,39 +145,45 @@ export class BookingsController {
|
||||
|
||||
@Post("/")
|
||||
async createBooking(
|
||||
@Req() req: Request & { userId?: number },
|
||||
@Body() _: CreateBookingInput
|
||||
@Req() req: BookingRequest,
|
||||
@Body() body: CreateBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.body = { ...req.body, noEmail: true };
|
||||
const oAuthClientId = clientId?.toString();
|
||||
const locationUrl = body.locationUrl;
|
||||
try {
|
||||
const booking = await handleNewBooking(req as unknown as NextApiRequest & { userId?: number });
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
} catch (err) {
|
||||
handleBookingErrors(err);
|
||||
this.handleBookingErrors(err);
|
||||
}
|
||||
throw new InternalServerErrorException("Could not create booking.");
|
||||
}
|
||||
|
||||
@Post("/:bookingId/cancel")
|
||||
async cancelBooking(
|
||||
@Req() req: Request & { userId?: number },
|
||||
@Req() req: BookingRequest,
|
||||
@Param("bookingId") bookingId: string,
|
||||
@Body() body: CancelBookingInput
|
||||
@Body() _: CancelBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
if (bookingId) {
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.body = { ...body, id: parseInt(bookingId) };
|
||||
try {
|
||||
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
|
||||
req.body.id = parseInt(bookingId);
|
||||
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (err) {
|
||||
handleBookingErrors(err);
|
||||
this.handleBookingErrors(err);
|
||||
}
|
||||
} else {
|
||||
throw new NotFoundException("Booking ID is required.");
|
||||
@@ -157,49 +191,56 @@ export class BookingsController {
|
||||
throw new InternalServerErrorException("Could not cancel booking.");
|
||||
}
|
||||
|
||||
@Post("/reccuring")
|
||||
async createReccuringBooking(
|
||||
@Req() req: Request & { userId?: number },
|
||||
@Body() _: CreateReccuringBookingInput[]
|
||||
@Post("/recurring")
|
||||
async createRecurringBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() _: CreateRecurringBookingInput[],
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<BookingResponse[]>> {
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.body = { ...req.body, noEmail: true };
|
||||
const oAuthClientId = clientId?.toString();
|
||||
try {
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||
req as unknown as NextApiRequest & { userId?: number }
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: createdBookings,
|
||||
};
|
||||
} catch (err) {
|
||||
handleBookingErrors(err, "recurring");
|
||||
this.handleBookingErrors(err, "recurring");
|
||||
}
|
||||
throw new InternalServerErrorException("Could not create recurring booking.");
|
||||
}
|
||||
|
||||
@Post("/instant")
|
||||
async createInstantBooking(
|
||||
@Req() req: Request & { userId?: number },
|
||||
@Body() _: CreateBookingInput
|
||||
@Req() req: BookingRequest,
|
||||
@Body() _: CreateBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.body = { ...req.body, noEmail: true };
|
||||
try {
|
||||
const instantMeeting = await handleInstantMeeting(
|
||||
req as unknown as NextApiRequest & { userId?: number }
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: instantMeeting,
|
||||
};
|
||||
} catch (err) {
|
||||
handleBookingErrors(err, "instant");
|
||||
this.handleBookingErrors(err, "instant");
|
||||
}
|
||||
throw new InternalServerErrorException("Could not create instant booking.");
|
||||
}
|
||||
|
||||
async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
try {
|
||||
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (accessToken) {
|
||||
@@ -209,19 +250,52 @@ export class BookingsController {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
|
||||
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
|
||||
if (err instanceof HttpError) {
|
||||
const httpError = err as HttpError;
|
||||
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
||||
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
|
||||
const res = DEFAULT_PLATFORM_PARAMS;
|
||||
try {
|
||||
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
|
||||
// fetch oAuthClient from db and use data stored in db to set these values
|
||||
if (client) {
|
||||
res.platformClientId = clientId;
|
||||
res.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
|
||||
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
|
||||
res.platformBookingUrl = client.bookingRedirectUri ?? "";
|
||||
res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
throw new InternalServerErrorException(error?.message ?? errMsg);
|
||||
private async createNextApiBookingRequest(
|
||||
req: BookingRequest,
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
||||
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(errMsg);
|
||||
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
|
||||
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
|
||||
if (err instanceof HttpError) {
|
||||
const httpError = err as HttpError;
|
||||
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
throw new InternalServerErrorException(error?.message ?? errMsg);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,4 +98,8 @@ export class CreateBookingInput {
|
||||
|
||||
@Type(() => Response)
|
||||
responses!: Response;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locationUrl?: string;
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateReccuringBookingInput extends CreateBookingInput {
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
noEmail?: boolean;
|
||||
@@ -11,7 +11,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "ee/calendars",
|
||||
path: "/calendars",
|
||||
version: "2",
|
||||
})
|
||||
@UseGuards(AccessTokenGuard)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CredentialsWithUserEmail,
|
||||
} from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Injectable,
|
||||
@@ -23,7 +24,8 @@ export class CalendarsService {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly dbRead: PrismaReadService
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
@@ -35,7 +37,7 @@ export class CalendarsService {
|
||||
return getConnectedDestinationCalendars(
|
||||
userWithCalendars,
|
||||
false,
|
||||
this.dbRead.prisma as unknown as PrismaClient
|
||||
this.dbWrite.prisma as unknown as PrismaClient
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { AppModule } from "@/app.module";
|
||||
import { EventTypesModule } from "@/ee/event-types/event-types.module";
|
||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
||||
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
|
||||
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
|
||||
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
@@ -64,7 +66,7 @@ describe("Event types Endpoints", () => {
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
|
||||
const userEmail = "event-types-test-e2e@api.com";
|
||||
const name = "bob the builder";
|
||||
const name = "bob-the-builder";
|
||||
const username = name;
|
||||
let eventType: EventType;
|
||||
let user: User;
|
||||
@@ -183,6 +185,41 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBody.data.eventType.userId).toEqual(user.id);
|
||||
});
|
||||
|
||||
it(`/GET/:username/public`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${username}/public`)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetEventTypesPublicOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
expect(responseBody.data?.[0]?.id).toEqual(eventType.id);
|
||||
expect(responseBody.data?.[0]?.title).toEqual(eventType.title);
|
||||
expect(responseBody.data?.[0]?.slug).toEqual(eventType.slug);
|
||||
expect(responseBody.data?.[0]?.length).toEqual(eventType.length);
|
||||
});
|
||||
|
||||
it(`/GET/:username/:eventSlug/public`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${username}/${eventType.slug}/public`)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetEventTypePublicOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.id).toEqual(eventType.id);
|
||||
expect(responseBody.data?.title).toEqual(eventType.title);
|
||||
expect(responseBody.data?.slug).toEqual(eventType.slug);
|
||||
expect(responseBody.data?.length).toEqual(eventType.length);
|
||||
});
|
||||
|
||||
it(`/GET/`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types`)
|
||||
@@ -202,7 +239,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBody.data.profiles?.[0]?.name).toEqual(name);
|
||||
});
|
||||
|
||||
it(`/GET/:username/public`, async () => {
|
||||
it(`/GET/public/:username/`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${username}/public`)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
||||
import { GetPublicEventTypeQueryParams } from "@/ee/event-types/inputs/get-public-event-type-query-params.input";
|
||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
||||
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
|
||||
import { DeleteEventTypeOutput } from "@/ee/event-types/outputs/delete-event-type.output";
|
||||
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
|
||||
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
|
||||
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
|
||||
import { GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
|
||||
import { GetEventTypesData, GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
|
||||
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
|
||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -24,11 +27,15 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries";
|
||||
import { getEventTypesByViewer } from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
path: "event-types",
|
||||
@@ -37,7 +44,10 @@ import { getEventTypesByViewer } from "@calcom/platform-libraries";
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
export class EventTypesController {
|
||||
constructor(private readonly eventTypesService: EventTypesService) {}
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService,
|
||||
private readonly prismaReadService: PrismaReadService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@@ -86,12 +96,40 @@ export class EventTypesController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
data: eventTypes as GetEventTypesData,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:username/:eventSlug/public")
|
||||
async getPublicEventType(
|
||||
@Param("username") username: string,
|
||||
@Param("eventSlug") eventSlug: string,
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams
|
||||
): Promise<GetEventTypePublicOutput> {
|
||||
try {
|
||||
const event = await getPublicEvent(
|
||||
username.toLowerCase(),
|
||||
eventSlug,
|
||||
queryParams.isTeamEvent,
|
||||
queryParams.org || null,
|
||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||
true
|
||||
);
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw new NotFoundException(err.message);
|
||||
}
|
||||
}
|
||||
throw new InternalServerErrorException("Could not find public event.");
|
||||
}
|
||||
|
||||
@Get("/:username/public")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
async getPublicEventTypes(@Param("username") username: string): Promise<GetEventTypesPublicOutput> {
|
||||
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository {
|
||||
@@ -48,9 +49,7 @@ export class EventTypesRepository {
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbRead.prisma,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
|
||||
+1
-10
@@ -2,16 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class GetPublicEventInput {
|
||||
@IsString()
|
||||
@Transform(({ value }: { value: string }) => value.toLowerCase())
|
||||
@ApiProperty({ required: true })
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ required: true })
|
||||
eventSlug!: string;
|
||||
|
||||
export class GetPublicEventTypeQueryParams {
|
||||
@Transform(({ value }: { value: string }) => value === "true")
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@@ -59,7 +59,8 @@ export class EventTypeOutput {
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
position!: number;
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
@@ -71,7 +72,8 @@ export class EventTypeOutput {
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
profileId!: number | null;
|
||||
@IsOptional()
|
||||
profileId?: number | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
@@ -83,7 +85,8 @@ export class EventTypeOutput {
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
parentId!: number | null;
|
||||
@IsOptional()
|
||||
parentId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@@ -176,7 +179,8 @@ export class EventTypeOutput {
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
scheduleId!: number | null;
|
||||
@IsOptional()
|
||||
scheduleId?: number | null;
|
||||
|
||||
@IsNumber()
|
||||
@ApiHideProperty()
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
} from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Location {
|
||||
@IsString()
|
||||
type!: string;
|
||||
}
|
||||
|
||||
class Source {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
label!: string;
|
||||
}
|
||||
|
||||
class OptionInput {
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
required?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
class BookingField {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultLabel?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
placeholder?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
getOptionsAt?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
optionsInputs?: { [key: string]: OptionInput };
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
hideWhenJustOneOption?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
editable?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Source)
|
||||
@IsOptional()
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
class Organization {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
slug?: string | null;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata!: Record<string, any>;
|
||||
}
|
||||
|
||||
class Profile {
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsInt()
|
||||
id!: number | null;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
userId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
uid?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsInt()
|
||||
organizationId!: number | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Organization)
|
||||
organization?: Organization | null;
|
||||
|
||||
@IsString()
|
||||
upId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
image?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
brandColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
darkBrandColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
bookerLayouts?: any;
|
||||
}
|
||||
|
||||
class Owner {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsString()
|
||||
name!: string | null;
|
||||
|
||||
@IsString()
|
||||
weekStart!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
brandColor?: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
darkBrandColor?: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
theme?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
metadata!: any;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
defaultScheduleId?: number | null;
|
||||
|
||||
@IsString()
|
||||
nonProfileUsername!: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Profile)
|
||||
profile!: Profile;
|
||||
}
|
||||
|
||||
class User {
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsString()
|
||||
name!: string | null;
|
||||
|
||||
@IsString()
|
||||
weekStart!: string;
|
||||
|
||||
@IsInt()
|
||||
organizationId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
profile!: Profile;
|
||||
|
||||
@IsString()
|
||||
bookerUrl!: string;
|
||||
}
|
||||
|
||||
class Schedule {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string | null;
|
||||
}
|
||||
|
||||
class PublicEventTypeOutput {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
eventName?: string | null;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsBoolean()
|
||||
isInstantEvent!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
aiPhoneCallConfig?: any;
|
||||
|
||||
@IsOptional()
|
||||
schedulingType?: any;
|
||||
|
||||
@IsInt()
|
||||
length!: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Location)
|
||||
locations!: Location[];
|
||||
|
||||
@IsArray()
|
||||
customInputs!: any[];
|
||||
|
||||
@IsBoolean()
|
||||
disableGuests!: boolean;
|
||||
|
||||
@IsObject()
|
||||
metadata!: object | null;
|
||||
|
||||
@IsBoolean()
|
||||
lockTimeZoneToggleOnBookingPage!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
requiresConfirmation!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
requiresBookerEmailVerification!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
recurringEvent?: any;
|
||||
|
||||
@IsNumber()
|
||||
price!: number;
|
||||
|
||||
@IsString()
|
||||
currency!: string;
|
||||
|
||||
@IsOptional()
|
||||
seatsPerTimeSlot?: number | null;
|
||||
|
||||
@IsBoolean()
|
||||
seatsShowAvailabilityCount!: boolean | null;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BookingField)
|
||||
bookingFields!: BookingField[];
|
||||
|
||||
@IsOptional()
|
||||
team?: any;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
successRedirectUrl?: string | null;
|
||||
|
||||
@IsArray()
|
||||
workflows!: any[];
|
||||
|
||||
@IsArray()
|
||||
hosts!: any[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Owner)
|
||||
owner!: Owner | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Schedule)
|
||||
schedule!: Schedule | null;
|
||||
|
||||
@IsBoolean()
|
||||
hidden!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
assignAllTeamMembers!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
bookerLayouts?: any;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => User)
|
||||
users!: User[];
|
||||
|
||||
@IsObject()
|
||||
entity!: object;
|
||||
|
||||
@IsBoolean()
|
||||
isDynamic!: boolean;
|
||||
}
|
||||
|
||||
export class GetEventTypePublicOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PublicEventTypeOutput)
|
||||
@IsArray()
|
||||
data!: PublicEventTypeOutput | null;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class EventTypeGroup {
|
||||
eventTypes!: EventTypeOutput[];
|
||||
}
|
||||
|
||||
class GetEventTypesData {
|
||||
export class GetEventTypesData {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeGroup)
|
||||
@IsArray()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
|
||||
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
|
||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
||||
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
@@ -22,7 +23,7 @@ export class EventTypesService {
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
|
||||
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput): Promise<EventTypeOutput> {
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
const { eventType } = await createEventType({
|
||||
@@ -34,7 +35,7 @@ export class EventTypesService {
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
return eventType;
|
||||
return eventType as EventTypeOutput;
|
||||
}
|
||||
|
||||
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
|
||||
@@ -88,7 +89,7 @@ export class EventTypesService {
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
return eventType as { eventType: EventTypeOutput };
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
@@ -126,9 +127,13 @@ export class EventTypesService {
|
||||
},
|
||||
});
|
||||
|
||||
const { eventType } = await this.getUserEventTypeForAtom(user, eventTypeId);
|
||||
const eventType = await this.getUserEventTypeForAtom(user, eventTypeId);
|
||||
|
||||
return eventType;
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
|
||||
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
@@ -18,6 +19,20 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
|
||||
class CalendarsServiceMock {
|
||||
async getCalendars() {
|
||||
return {
|
||||
connectedCalendars: [
|
||||
{
|
||||
integration: {
|
||||
type: "google_calendar",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Platform Gcal Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -42,6 +57,8 @@ describe("Platform Gcal Endpoints", () => {
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.overrideProvider(CalendarsService)
|
||||
.useClass(CalendarsServiceMock)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -85,14 +102,14 @@ describe("Platform Gcal Endpoints", () => {
|
||||
|
||||
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/ee/gcal/oauth/auth-url`)
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer invalid_access_token`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/ee/gcal/oauth/auth-url`)
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
@@ -103,7 +120,7 @@ describe("Platform Gcal Endpoints", () => {
|
||||
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
@@ -111,7 +128,7 @@ describe("Platform Gcal Endpoints", () => {
|
||||
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/ee/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
@@ -119,25 +136,25 @@ describe("Platform Gcal Endpoints", () => {
|
||||
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/ee/gcal/check`)
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/ee/gcal/check without access token`, async () => {
|
||||
await request(app.getHttpServer()).get(`/api/v2/ee/gcal/check`).expect(401);
|
||||
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/ee/gcal/check`)
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
@@ -151,7 +168,7 @@ describe("Platform Gcal Endpoints", () => {
|
||||
"google-calendar"
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/ee/gcal/check`)
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
|
||||
@@ -44,7 +44,7 @@ const CALENDAR_SCOPES = [
|
||||
|
||||
// Controller for the GCalConnect Atom
|
||||
@Controller({
|
||||
path: "ee/gcal",
|
||||
path: "/gcal",
|
||||
version: "2",
|
||||
})
|
||||
@DocsTags("Google Calendar")
|
||||
@@ -60,7 +60,7 @@ export class GcalController {
|
||||
private readonly calendarsService: CalendarsService
|
||||
) {}
|
||||
|
||||
private redirectUri = `${this.config.get("api.url")}/ee/gcal/oauth/save`;
|
||||
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
|
||||
@Get("/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("Me Endpoints", () => {
|
||||
|
||||
it("should get user associated with access token", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/api/v2/ee/me")
|
||||
.get("/v2/me")
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
|
||||
@@ -90,7 +90,7 @@ describe("Me Endpoints", () => {
|
||||
const body: UpdateManagedUserInput = { timeZone: "Europe/Rome" };
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch("/api/v2/ee/me")
|
||||
.patch("/v2/me")
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -114,22 +114,19 @@ describe("Me Endpoints", () => {
|
||||
it("should not update user associated with access token given invalid timezone", async () => {
|
||||
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
|
||||
|
||||
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectTimeZone).expect(400);
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeZone).expect(400);
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid time format", async () => {
|
||||
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 };
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch("/api/v2/ee/me")
|
||||
.send(bodyWithIncorrectTimeFormat)
|
||||
.expect(400);
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid week start", async () => {
|
||||
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" };
|
||||
|
||||
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectWeekStart).expect(400);
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
|
||||
import { userSchemaResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "ee/me",
|
||||
path: "/me",
|
||||
version: "2",
|
||||
})
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "ee/provider",
|
||||
path: "/provider",
|
||||
version: "2",
|
||||
})
|
||||
@DocsTags("Cal provider")
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
@@ -70,6 +71,7 @@ export class SchedulesController {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
|
||||
@@ -24,6 +24,16 @@ export class SchedulesService {
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
const schedule = {
|
||||
isDefault: true,
|
||||
name: "Default schedule",
|
||||
timeZone,
|
||||
};
|
||||
|
||||
return this.createUserSchedule(userId, schedule);
|
||||
}
|
||||
|
||||
async createUserSchedule(userId: number, schedule: CreateScheduleInput) {
|
||||
const availabilities = schedule.availabilities?.length
|
||||
? schedule.availabilities
|
||||
|
||||
@@ -12,6 +12,9 @@ export type Environment = {
|
||||
SENTRY_DSN: string;
|
||||
LOG_LEVEL: keyof typeof logLevels;
|
||||
REDIS_URL: string;
|
||||
STRIPE_API_KEY: string;
|
||||
STRIPE_WEBHOOK_SECRET: string;
|
||||
WEB_APP_URL: string;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -13,13 +13,17 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
status: ERROR_STATUS,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -33,12 +33,15 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
status: ERROR_STATUS,
|
||||
|
||||
@@ -14,6 +14,7 @@ export class SentryFilter extends BaseExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
@@ -21,6 +22,7 @@ export class SentryFilter extends BaseExceptionFilter {
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
// capture if client has been init
|
||||
|
||||
@@ -41,12 +41,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -14,6 +14,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
@@ -21,6 +22,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { loggerConfig } from "./lib/logger";
|
||||
const run = async () => {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
bodyParser: false,
|
||||
});
|
||||
|
||||
const logger = new Logger("App");
|
||||
@@ -23,10 +24,13 @@ const run = async () => {
|
||||
try {
|
||||
bootstrap(app);
|
||||
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
||||
generateSwagger(app);
|
||||
if (process.env.SKIP_DOCS_GENERATION !== "1") {
|
||||
void generateSwagger(app);
|
||||
}
|
||||
await app.listen(port);
|
||||
logger.log(`Application started on port: ${port}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error("Application crashed", {
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class RewriterMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: () => void) {
|
||||
if (req.url.startsWith("/api/v2")) {
|
||||
req.url = req.url.replace("/api/v2", "/v2");
|
||||
}
|
||||
if (req.url.startsWith("/v2/ee")) {
|
||||
req.url = req.url.replace("/v2/ee", "/v2");
|
||||
}
|
||||
if (req.url.includes("reccuring")) {
|
||||
req.url = req.url.replace("reccuring", "recurring");
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import * as bodyParser from "body-parser";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class JsonBodyMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: () => any) {
|
||||
bodyParser.json()(req, res, next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import * as bodyParser from "body-parser";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class RawBodyMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: () => any) {
|
||||
bodyParser.raw({ type: "*/*" })(req, res, next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const ctx = context.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
req.headers["X-Request-Id"] = uuid();
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import { ApiKeyAuthStrategy } from "@/modules/auth/strategies/api-key-auth/api-k
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule],
|
||||
imports: [PassportModule, RedisModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule],
|
||||
providers: [
|
||||
ApiKeyAuthStrategy,
|
||||
NextAuthGuard,
|
||||
|
||||
@@ -27,7 +27,10 @@ export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-
|
||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
await this.oauthFlowService.validateAccessToken(accessToken);
|
||||
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
||||
if (!accessTokenValid) {
|
||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
if (!client) {
|
||||
|
||||
@@ -19,12 +19,17 @@ export class CreateAvailabilityInput {
|
||||
}
|
||||
|
||||
function transformStringToDate(value: string, key: string): Date {
|
||||
const parts = value.split(":");
|
||||
// note(Lauris): incoming value is ISO8061 e.g. 2025-0412T13:17:56.324Z
|
||||
const dateTimeParts = value.split("T");
|
||||
|
||||
const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
|
||||
const parts = timePart.split(":");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new BadRequestException(`Invalid ${key} format. Expected format: HH:MM:SS. Received ${value}`);
|
||||
throw new BadRequestException(
|
||||
`Invalid time format. Expected format(ISO8061): 2025-0412T13:17:56.324Z. Received: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
const [hours, minutes, seconds] = parts.map(Number);
|
||||
|
||||
if (hours < 0 || hours > 23) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository],
|
||||
exports: [BillingService, BillingRepository],
|
||||
controllers: [BillingController],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BillingRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
getBillingForTeam = (teamId: number) =>
|
||||
this.dbRead.prisma.platformBilling.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
async updateTeamBilling(
|
||||
teamId: number,
|
||||
billingStart: number,
|
||||
billingEnd: number,
|
||||
plan: PlatformPlan,
|
||||
subscription?: string
|
||||
) {
|
||||
return this.dbWrite.prisma.platformBilling.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
data: {
|
||||
billingCycleStart: billingStart,
|
||||
billingCycleEnd: billingEnd,
|
||||
subscriptionId: subscription,
|
||||
plan: plan.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import { Stripe } from "stripe";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/billing",
|
||||
version: "2",
|
||||
})
|
||||
@ApiExcludeController(true)
|
||||
export class BillingController {
|
||||
private readonly stripeWhSecret: string;
|
||||
private logger = new Logger("Billing Controller");
|
||||
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
private readonly configService: ConfigService<AppConfig>
|
||||
) {
|
||||
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
|
||||
}
|
||||
|
||||
@Get("/:teamId/check")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Roles(["OWNER", "ADMIN"])
|
||||
async checkTeamBilling(
|
||||
@Param("teamId") teamId: number
|
||||
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
||||
const { status, plan } = await this.billingService.getBillingData(teamId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
valid: status === "valid",
|
||||
plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:teamId/subscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Roles(["OWNER", "ADMIN"])
|
||||
async subscribeTeamToStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const { status } = await this.billingService.getBillingData(teamId);
|
||||
|
||||
if (status === "valid") {
|
||||
throw new BadRequestException("This team is already subscribed to a plan.");
|
||||
}
|
||||
|
||||
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
|
||||
if (action === "redirect") {
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
action: "redirect",
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/webhook")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async stripeWebhook(
|
||||
@Req() request: Request,
|
||||
@Headers("stripe-signature") stripeSignature: string
|
||||
): Promise<ApiResponse> {
|
||||
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
|
||||
request.body,
|
||||
stripeSignature,
|
||||
this.stripeWhSecret
|
||||
);
|
||||
|
||||
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!subscription.metadata?.teamId) {
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(subscription.metadata.teamId);
|
||||
const plan = subscription.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
await this.billingService.setSubscriptionForTeam(
|
||||
teamId,
|
||||
subscription,
|
||||
PlatformPlan[plan as keyof typeof PlatformPlan]
|
||||
);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
export class SubscribeToPlanInput {
|
||||
@IsEnum(PlatformPlan)
|
||||
plan!: PlatformPlan;
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export class CheckPlatformBillingResponseDto {
|
||||
valid!: boolean;
|
||||
|
||||
plan?: string;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export class SubscribeTeamToBillingResponseDto {
|
||||
url?: string;
|
||||
action?: "redirect";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BillingConfigService {
|
||||
private readonly config: Map<PlatformPlan, string>;
|
||||
|
||||
constructor() {
|
||||
this.config = new Map<PlatformPlan, string>();
|
||||
|
||||
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
|
||||
for (const key of planKeys) {
|
||||
this.config.set(
|
||||
PlatformPlan[key as keyof typeof PlatformPlan],
|
||||
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get(plan: PlatformPlan): string | undefined {
|
||||
return this.config.get(plan);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DateTime } from "luxon";
|
||||
import Stripe from "stripe";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private logger = new Logger("BillingService");
|
||||
private readonly webAppUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly teamsRepository: OrganizationsRepository,
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService
|
||||
) {
|
||||
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
}
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
if (teamWithBilling?.platformBilling) {
|
||||
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
||||
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
|
||||
}
|
||||
|
||||
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
|
||||
} else {
|
||||
return { team: teamWithBilling, status: "no_billing", plan: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let brandNewBilling = false;
|
||||
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
if (!teamWithBilling?.platformBilling) {
|
||||
brandNewBilling = true;
|
||||
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
|
||||
|
||||
this.logger.log("Team had no Stripe Customer ID, created one for them.", {
|
||||
id: teamId,
|
||||
stripeId: customerId,
|
||||
});
|
||||
}
|
||||
|
||||
if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) {
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: this.billingConfigService.get(plan),
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
|
||||
mode: "subscription",
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return { action: "redirect", url };
|
||||
}
|
||||
|
||||
return { action: "none" };
|
||||
}
|
||||
|
||||
async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
|
||||
const billingCycleStart = DateTime.now().get("day");
|
||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||
|
||||
return this.billingRepository.updateTeamBilling(
|
||||
teamId,
|
||||
billingCycleStart,
|
||||
billingCycleEnd,
|
||||
plan,
|
||||
subscription.id
|
||||
);
|
||||
}
|
||||
|
||||
async increaseUsageForTeam(teamId: number) {
|
||||
try {
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
this.logger.error("Team did not have stripe subscription associated to it", {
|
||||
teamId,
|
||||
});
|
||||
return void 0;
|
||||
}
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
const item = stripeSubscription.items.data[0];
|
||||
// legacy plans are licensed, we cannot create usage records against them
|
||||
if (item.price?.recurring?.usage_type === "licensed") {
|
||||
return void 0;
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
} catch (error) {
|
||||
// don't fail the request, log it.
|
||||
this.logger.error("Failed to increase usage for team", {
|
||||
teamId: teamId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async increaseUsageByClientId(clientId: string) {
|
||||
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
||||
if (!team.id) return Promise.resolve(); // noop resolution.
|
||||
|
||||
return this.increaseUsageForTeam(team?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum PlatformPlan {
|
||||
STARTER = "STARTER",
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export class CredentialsRepository {
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credential?.id,
|
||||
id: credential?.id ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
|
||||
import { EventsModule } from "@/modules/events/events.module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
|
||||
imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule],
|
||||
})
|
||||
export class EndpointsModule implements NestModule {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Controller, Get, NotFoundException, InternalServerErrorException, Query } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries";
|
||||
import type { PublicEventType } from "@calcom/platform-libraries";
|
||||
import { ApiResponse, GetPublicEventInput } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
path: "events",
|
||||
version: "2",
|
||||
})
|
||||
@DocsTags("Event types")
|
||||
export class EventsController {
|
||||
constructor(private readonly prismaReadService: PrismaReadService) {}
|
||||
|
||||
@Get("/public")
|
||||
async getPublicEvent(@Query() queryParams: GetPublicEventInput): Promise<ApiResponse<PublicEventType>> {
|
||||
try {
|
||||
const event = await getPublicEvent(
|
||||
queryParams.username,
|
||||
queryParams.eventSlug,
|
||||
queryParams.isTeamEvent,
|
||||
queryParams.org || null,
|
||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||
true
|
||||
);
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw new NotFoundException(err.message);
|
||||
}
|
||||
}
|
||||
throw new InternalServerErrorException("Could not find public event.");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { EventsController } from "@/modules/events/controllers/events.controller";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [EventsController],
|
||||
})
|
||||
export class EventsModule {}
|
||||
+20
-3
@@ -17,6 +17,7 @@ import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
|
||||
@@ -75,10 +76,12 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
let postResponseData: CreateUserResponse;
|
||||
|
||||
const userEmail = "oauth-client-user@gmail.com";
|
||||
const userTimeZone = "Europe/Rome";
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@@ -93,6 +96,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
@@ -134,6 +138,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
it(`/POST`, async () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: userEmail,
|
||||
timeZone: userTimeZone,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -158,6 +163,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
|
||||
await userConnectedToOAuth(responseBody.data.user.email);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
|
||||
});
|
||||
|
||||
async function userConnectedToOAuth(userEmail: string) {
|
||||
@@ -181,6 +187,17 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
async function userHasDefaultSchedule(userId: number, scheduleId: number | null) {
|
||||
expect(scheduleId).toBeDefined();
|
||||
expect(scheduleId).not.toBeNull();
|
||||
|
||||
const user = await userRepositoryFixture.get(userId);
|
||||
expect(user?.defaultScheduleId).toEqual(scheduleId);
|
||||
|
||||
const schedule = scheduleId ? await schedulesRepositoryFixture.getById(scheduleId) : null;
|
||||
expect(schedule?.userId).toEqual(userId);
|
||||
}
|
||||
|
||||
it(`/GET: return list of managed users`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`)
|
||||
@@ -199,7 +216,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(200);
|
||||
|
||||
@@ -216,7 +233,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.send(body)
|
||||
.expect(200);
|
||||
@@ -231,7 +248,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
it(`/DELETE/:id`, () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
-5
@@ -76,11 +76,6 @@ export class OAuthClientUsersController {
|
||||
this.logger.log(
|
||||
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
|
||||
);
|
||||
const existingUser = await this.userRepository.findByEmail(body.email);
|
||||
|
||||
if (existingUser) {
|
||||
throw new BadRequestException("A user with the provided email already exists.");
|
||||
}
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
|
||||
const isPlatformManaged = true;
|
||||
|
||||
+12
-1
@@ -8,6 +8,7 @@ import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/o
|
||||
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
|
||||
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Body,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Logger,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
@@ -47,7 +49,10 @@ Second, make sure that the logged in user has organizationId set to pass the Org
|
||||
export class OAuthClientsController {
|
||||
private readonly logger = new Logger("OAuthClientController");
|
||||
|
||||
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@@ -65,6 +70,12 @@ export class OAuthClientsController {
|
||||
this.logger.log(
|
||||
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
|
||||
);
|
||||
|
||||
const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId);
|
||||
if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) {
|
||||
throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client.");
|
||||
}
|
||||
|
||||
const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, ERROR_STATUS, REDIRECT_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class DataDto {
|
||||
@ApiProperty({
|
||||
@@ -34,3 +34,9 @@ export class CreateOAuthClientResponseDto {
|
||||
@Type(() => DataDto)
|
||||
data!: DataDto;
|
||||
}
|
||||
|
||||
export class CreateOauthClientRedirect {
|
||||
status!: typeof REDIRECT_STATUS;
|
||||
|
||||
url!: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventTypesModule } from "@/ee/event-types/event-types.module";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
|
||||
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
|
||||
@@ -10,6 +12,8 @@ import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -19,12 +23,16 @@ import { Global, Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
MembershipsModule,
|
||||
EventTypesModule,
|
||||
OrganizationsModule,
|
||||
StripeModule,
|
||||
BillingModule,
|
||||
SchedulesModule,
|
||||
],
|
||||
providers: [
|
||||
OAuthClientRepository,
|
||||
|
||||
@@ -59,7 +59,7 @@ export class OAuthClientRepository {
|
||||
}
|
||||
|
||||
async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) {
|
||||
return await this.dbRead.prisma.platformOAuthClient.findFirst({
|
||||
return this.dbRead.prisma.platformOAuthClient.findFirst({
|
||||
where: {
|
||||
id: clientId,
|
||||
secret: clientSecret,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
@@ -6,15 +7,15 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
constructor(
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly eventTypesService: EventTypesService
|
||||
private readonly eventTypesService: EventTypesService,
|
||||
private readonly schedulesService: SchedulesService
|
||||
) {}
|
||||
|
||||
async createOauthClientUser(
|
||||
@@ -23,7 +24,7 @@ export class OAuthClientUsersService {
|
||||
isPlatformManaged: boolean,
|
||||
organizationId?: number
|
||||
) {
|
||||
const existsWithEmail = await this.userExistsWithEmail(oAuthClientId, body.email);
|
||||
const existsWithEmail = await this.managedUserExistsWithEmail(oAuthClientId, body.email);
|
||||
if (existsWithEmail) {
|
||||
throw new BadRequestException("User with the provided e-mail already exists.");
|
||||
}
|
||||
@@ -62,8 +63,14 @@ export class OAuthClientUsersService {
|
||||
oAuthClientId,
|
||||
user.id
|
||||
);
|
||||
|
||||
await this.eventTypesService.createUserDefaultEventTypes(user.id);
|
||||
|
||||
if (body.timeZone) {
|
||||
const defaultSchedule = await this.schedulesService.createUserDefaultSchedule(user.id, body.timeZone);
|
||||
user.defaultScheduleId = defaultSchedule.id;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
@@ -73,7 +80,7 @@ export class OAuthClientUsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async userExistsWithEmail(oAuthClientId: string, email: string) {
|
||||
async managedUserExistsWithEmail(oAuthClientId: string, email: string) {
|
||||
const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email);
|
||||
const user = await this.userRepository.findByEmail(oAuthEmail);
|
||||
return !!user;
|
||||
@@ -92,8 +99,6 @@ export class OAuthClientUsersService {
|
||||
|
||||
getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
const email = `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
|
||||
return email;
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,70 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthFlowService {
|
||||
private logger = new Logger("OAuthFlowService");
|
||||
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async propagateAccessToken(accessToken: string) {
|
||||
// this.logger.log("Propagating access token to redis", accessToken);
|
||||
// TODO propagate
|
||||
//this.redisService.redis.hset("access_tokens", accessToken,)
|
||||
return void 0;
|
||||
try {
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
let expiry = await this.tokensRepository.getAccessTokenExpiryDate(accessToken);
|
||||
|
||||
if (!expiry) {
|
||||
this.logger.warn(`Token for ${ownerId} had no expiry time, assuming it's new.`);
|
||||
expiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
}
|
||||
|
||||
const cacheKey = this._generateActKey(accessToken);
|
||||
await this.redisService.redis.hmset(cacheKey, {
|
||||
ownerId: ownerId,
|
||||
expiresAt: expiry?.toJSON(),
|
||||
});
|
||||
|
||||
await this.redisService.redis.expireat(cacheKey, Math.floor(expiry.getTime() / 1000));
|
||||
} catch (err) {
|
||||
this.logger.error("Access Token Propagation Failed, falling back to DB...", err);
|
||||
}
|
||||
}
|
||||
|
||||
async getOwnerId(accessToken: string) {
|
||||
return this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const cacheKey = this._generateActKey(accessToken);
|
||||
|
||||
try {
|
||||
const ownerId = await this.redisService.redis.get(cacheKey);
|
||||
if (ownerId) {
|
||||
return Number.parseInt(ownerId);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn("Cache#getOwnerId fetch failed, falling back to DB...");
|
||||
}
|
||||
|
||||
const ownerIdFromDb = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
|
||||
|
||||
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
|
||||
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
|
||||
|
||||
return ownerIdFromDb;
|
||||
}
|
||||
|
||||
async validateAccessToken(secret: string) {
|
||||
// status can be "CACHE_HIT" or "CACHE_MISS", MISS will most likely mean the token has expired
|
||||
// but we need to check the SQL db for it anyways.
|
||||
const { status } = await this.readFromCache(secret);
|
||||
const { status, cacheKey } = await this.readFromCache(secret);
|
||||
|
||||
if (status === "CACHE_HIT") {
|
||||
return true;
|
||||
@@ -43,12 +80,23 @@ export class OAuthFlowService {
|
||||
throw new TokenExpiredException();
|
||||
}
|
||||
|
||||
// we can't use a Promise#all or similar here because we care about execution order
|
||||
// however we can't allow caches to fail a validation hence the results are voided.
|
||||
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
|
||||
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private async readFromCache(secret: string) {
|
||||
return { status: "CACHE_MISS" };
|
||||
const cacheKey = this._generateActKey(secret);
|
||||
const tokenData = await this.redisService.redis.hgetall(cacheKey);
|
||||
|
||||
if (tokenData && new Date() < new Date(tokenData.expiresAt)) {
|
||||
return { status: "CACHE_HIT", cacheKey };
|
||||
}
|
||||
|
||||
return { status: "CACHE_MISS", cacheKey };
|
||||
}
|
||||
|
||||
async exchangeAuthorizationToken(
|
||||
@@ -77,7 +125,7 @@ export class OAuthFlowService {
|
||||
authorizationToken.owner.id
|
||||
);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
void this.propagateAccessToken(accessToken); // voided as we don't need to await
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@@ -113,4 +161,8 @@ export class OAuthFlowService {
|
||||
refreshToken: refreshToken.secret,
|
||||
};
|
||||
}
|
||||
|
||||
private _generateActKey(accessToken: string) {
|
||||
return `act_${accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, StripeModule],
|
||||
providers: [OrganizationsRepository, OrganizationsService],
|
||||
exports: [OrganizationsService],
|
||||
exports: [OrganizationsService, OrganizationsRepository],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async findById(organizationId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
@@ -12,4 +19,54 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdIncludeBilling(orgId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
include: {
|
||||
platformBilling: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
|
||||
const { id } = await this.stripeService.stripe.customers.create({
|
||||
metadata: {
|
||||
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
|
||||
},
|
||||
});
|
||||
|
||||
await this.dbWrite.prisma.team.update({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
data: {
|
||||
platformBilling: {
|
||||
create: {
|
||||
customerId: id,
|
||||
plan: plan ? plan.toString() : "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async findTeamIdFromClientId(clientId: string) {
|
||||
return this.dbRead.prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
platformOAuthClient: {
|
||||
some: {
|
||||
id: clientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
||||
|
||||
@Injectable()
|
||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||
// TODO: adapt if required for CF / AWS / FlightControl proxying.
|
||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
||||
return req.ips.length ? req.ips[0] : req.ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
public redis: Redis;
|
||||
|
||||
constructor(readonly configService: ConfigService<AppConfig>) {
|
||||
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
||||
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
||||
|
||||
this.redis = new Redis(dbUrl);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redis.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
exports: [StripeService],
|
||||
providers: [StripeService],
|
||||
})
|
||||
export class StripeModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import Stripe from "stripe";
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
public stripe: Stripe;
|
||||
|
||||
constructor(configService: ConfigService<AppConfig>) {
|
||||
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export class TokensRepository {
|
||||
// discard.
|
||||
}
|
||||
}
|
||||
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,11 @@
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@prisma/client/*": ["@calcom/prisma/client/*"]
|
||||
"@prisma/client/*": ["@calcom/prisma/client/*"],
|
||||
"@calcom/platform-libraries": ["../../../packages/platform/libraries/index.ts"],
|
||||
"@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"],
|
||||
"@calcom/platform-types": ["../../../packages/platform/types/index.ts"],
|
||||
"@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -23,6 +27,17 @@
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"exclude": ["./dist", "next-i18next.config.js"],
|
||||
"include": ["./**/*.ts", "../../../packages/types/*.d.ts"]
|
||||
"exclude": ["./dist", "./node_modules", "next-i18next.config.js"],
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"../../../packages/types/*.d.ts",
|
||||
"../../../packages/platform/libraries/**/*.ts",
|
||||
"../../../packages/platform/libraries/**/*.d.ts",
|
||||
"../../../packages/platform/constants/**/*.ts",
|
||||
"../../../packages/platform/constants/**/*.d.ts",
|
||||
"../../../packages/platform/types/**/*.ts",
|
||||
"../../../packages/platform/types/**/*.d.ts",
|
||||
"../../../packages/platform/utils/**/*.ts",
|
||||
"../../../packages/platform/utils/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Hello World
|
||||
@@ -1 +0,0 @@
|
||||
## Cal.com Platform Example (https://platform.cal.com)
|
||||
@@ -1,5 +1,14 @@
|
||||
# @calcom/web
|
||||
|
||||
## 3.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @calcom/embed-core@1.4.0
|
||||
- @calcom/embed-react@1.4.0
|
||||
- @calcom/embed-snippet@1.2.0
|
||||
|
||||
## 3.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from "@calcom/features/tasker/api/cleanup";
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from "@calcom/features/tasker/api/cron";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user