Compare commits

...
52 Commits
Author SHA1 Message Date
Erik b96786125c v3.9.4
release.yml / release (push) Canceled after 0s
2024-04-02 15:32:21 -03:00
Erik 9451f38cec chore: SDK Fixes and add swagger docs (#14218)
* chore: fixes and add swagger docs

* chore: add script
2024-04-02 18:31:01 +00:00
Keith Williams 306f53dec7 fix: Typo in OAuth clients (#14292)
* fix: Typo in OAuth clients

* Update index.tsx
2024-04-02 15:02:26 -03:00
Erik db865a680f chore: hotfixes (#14297) 2024-04-02 17:48:48 +00:00
Rajiv Sahal aca552c9f7 fix: Booker atom modal not rendering for mobile devices (#14278)
* make sure modal always appears

* separate event form wrappers into two components

* platform hook to fetch event type by id

* testing useGetEventTypeById hook

* add useGetEventTypeById to monorepo exports

* use platform hook for event type by id
2024-04-02 20:41:28 +03:00
Erik b202c4e79f chore: sentry dsn (#14295) 2024-04-02 17:01:00 +00:00
Peer Richelsen 972ce6911a Update Shell.tsx (#14293) 2024-04-02 16:23:03 +01:00
Peer Richelsen 751530b460 Create README.md 2024-04-02 15:25:02 +01:00
3b263e9282 feat: v2 event-types POST, PATCH, DELETE endpoints (#14237)
* create event type: use createEventType from trpc handler

* create event type: simplify handler input and create e2e test

* update event type + test

* delete end point

* check if owner owns event type

* fix: starting v2

* fixes

* remove unused enum

* fix TS issue for update input

* docs refresh

* docs

* docs

* simplify event types docs

* docs refactor

* fix: check if user already has event type with existing slug

* refactor update into a service

* fix tests

* remove unused import

* update create event link

* refactor: create event handler process passed locations

* move input out of folder

* reuse eventtype location for inputs

* docs: example POST event-type return

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-02 15:50:12 +02:00
Hariom BalharaandUdit Takkar 2cbf9e1ac8 fix: Show proper error in case the team can't be published because requestedSlug isn't set (#14283)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:44:39 +00:00
Udit TakkarandCarina Wollendorfer 6283535d6a chore: add more duration options for multiple duration events (#14162)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-04-02 12:43:24 +00:00
Amit SharmaandUdit Takkar a03ae81509 fix: cancellation seems to be using slug instead of title (#14195)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:42:48 +00:00
Alex van Andel a7e05aa016 v3.9.3
release.yml / release (push) Canceled after 0s
2024-04-02 13:37:09 +01:00
MorganandAlex van Andel f8cbf8b1c4 chore: rewrite api.cal.com/v2 to api v2 (#14285)
* chore: rewrite api.cal.com/v2 to api v2

* fixup! chore: rewrite api.cal.com/v2 to api v2

* fixup! fixup! chore: rewrite api.cal.com/v2 to api v2

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-04-02 13:23:04 +01:00
Alex van Andel ccc294e567 chore: Skip broken out-of-office tests (#14286) 2024-04-02 11:24:42 +00:00
Crowdin Bot 0be344b1ba New Crowdin translations by Github Action 2024-04-01 12:33:52 +00:00
Shyam Raghuwanshi 7a525e476a fix Bookings Teams filter shows double label (#14239) 2024-04-01 18:00:04 +05:30
Lauris Skraucis fe62f9c6de refactor: OAuth creation form hide apps permissions (#14264) 2024-03-29 10:35:11 +00:00
Crowdin Bot 9608f6518f New Crowdin translations by Github Action 2024-03-29 09:14:37 +00:00
Lauris Skraucis 2f8fa20ec6 refactor: prisma platform tables use pascal case (#14263) 2024-03-29 09:10:28 +00:00
Amit Sharma 6f097b0a74 fix: No handlers found for ics-feed (#14123) 2024-03-28 16:01:27 -03:00
Omar López 159bbb53b6 chore: adds useful SQL scripts for devops (#14068) 2024-03-28 11:36:27 -07:00
Alex van Andel 622160ea17 Revert "feat: Migrate all old avatars to 'avatars' table (#14170)"
This reverts commit 4bfbac6649.
2024-03-28 18:14:58 +00:00
Philipp Dormann 5d543c864f fix(translations/de): typo in limit_booking_only_first_slot (#14244) 2024-03-28 15:09:47 -03:00
Keith Williams ed745623eb fix: Organization check (#14251) 2024-03-28 16:13:10 +00:00
Alex van Andel 29bca76e91 chore: Select organization fields to prevent logo import (#14255)
* chore: Select organization fields to prevent logo import

* teams query actually doesn't use org, keep for backwards compat
2024-03-28 12:54:45 -03:00
Rajiv SahalandMorgan Vernay 33d0fee43e chore: add styling to examples app (#14252)
* add styling to  examples app

* prettier homepage

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-03-28 15:48:51 +00:00
Crowdin Bot bb13de4176 New Crowdin translations by Github Action 2024-03-28 15:41:27 +00:00
Morgan c854dd092c refactor: platform timezone endpoint (#14250)
* refactor: platform timezone endpoint

* fixup! refactor: platform timezone endpoint

* fixup! fixup! refactor: platform timezone endpoint
2024-03-28 12:35:51 -03:00
Crowdin Bot 26d64bd93f New Crowdin translations by Github Action 2024-03-28 14:07:17 +00:00
1025238eee feat: cal.ai enterprise phone calls (#14100)
* added empty layout

* init

* yarn lock due to package changes

* added retell ai to .env

* removed ton of glue code

* nit

* Discard changes to package.json

* Upgrades lucide-react

* minor UI fixes

* nit

* nit

* feat: save progress

* feat: v1

* fix: type error

* feat: change schema

* fix: type error and testr

* chore: update agent

* chore: change default prompt

* feat: feedback and improvements

* fix: type error

* fix: type error

* hidden for now while in trial

* added i18n and removed some comments

* feat: add cal api key

* fix: type error

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-03-28 13:59:01 +00:00
sean-brydonandUdit Takkar eec76ecfe2 feat: implement serverside search over client side (#14234)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-03-28 13:18:16 +00:00
Morgan f5876ae6d6 chore: rename platform /me endpoints (#14249)
* chore: rename platform me endpoints

* fixup! chore: rename platform me endpoints
2024-03-28 12:28:32 +00:00
Morgan ccc8cdc1ff chore: rename platform endpoints provider/gcal (#14248) 2024-03-28 12:04:28 +00:00
Crowdin Bot a1840c9362 New Crowdin translations by Github Action 2024-03-28 08:10:02 +00:00
Morgan 22212e7edb fix: embed env var EnvrionmentPlugin config (#14225)
* fix: embed env var EnvrionmentPlugin config

* fixup! fix: embed env var EnvrionmentPlugin config

* fixup! fixup! fix: embed env var EnvrionmentPlugin config

* fix: auto switch to VERCEL_URL if WEBAPP_URL is undefined
2024-03-28 08:05:13 +00:00
Crowdin Bot 6ffe7005dd New Crowdin translations by Github Action 2024-03-27 18:52:03 +00:00
Crowdin Bot 4890d72e30 New Crowdin translations by Github Action 2024-03-27 18:42:37 +00:00
Crowdin Bot a531541273 New Crowdin translations by Github Action 2024-03-27 18:34:19 +00:00
Peer Richelsen 2bced59b47 chore: added vercel env to turbo repo (#14238) 2024-03-27 11:26:05 -07:00
Richard Poelderl e9554604e7 chore(ci): Add nextjs framework env vors to @calcom/website in turbo.json (#14231) 2024-03-27 14:04:27 +00:00
Crowdin Bot 48c51da853 New Crowdin translations by Github Action 2024-03-27 12:42:07 +00:00
cc2164657c feat: platform endpoints to fetch/cancel/reschedule bookings and hooks (#14164)
* init bookings endpoint

* abstracting functions

* e2e tests for bookings

* hooks for bookings endpoint

* bookings respository fixtures

* typings for booking input

* fixup

* abstract booking info code and use it as handler

* import handlers for bookings endpoint

* add cancel booking input

* add handleCancelBooking handler in platform libraries

* cancel booking endpoint

* abstract call into its own separate fn

* cancel booking hook

* e2e test for cancel booking endpoint

* fix import

* export getBookings function

* move getAllUserBookings into lib

* add bookings folder to package exports

* use getAllUserBookings from lib

* fix import path

* fix: hooks, endpoint and example for cancel / reschedule / list / booking success page

* fix: unit test mock classNames import from lib

* fix: unit test mock classNames import from lib

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-03-27 12:34:54 +00:00
Crowdin Bot a35683667b New Crowdin translations by Github Action 2024-03-27 08:19:25 +00:00
Zachary Jia cf403bebf1 fix: add missing multiple_duration_timeUnit translation in zh-CN (#14212) 2024-03-27 05:15:15 -03:00
Crowdin Bot f3e4bbc753 New Crowdin translations by Github Action 2024-03-26 23:36:45 +00:00
Alex van AndelandOmar López 4bfbac6649 feat: Migrate all old avatars to 'avatars' table (#14170)
Co-authored-by: Omar López <zomars@me.com>
2024-03-26 23:33:15 +00:00
Alex van Andel 3d7fd7eb01 chore: Set Github Actions PostgreSQL password 2024-03-26 22:58:42 +00:00
Crowdin Bot ec42900e25 New Crowdin translations by Github Action 2024-03-26 22:48:13 +00:00
Alex van Andel 032b243248 chore: bump GitHub postgres v2 (#14220)
* Bump all postgres:12 -> postgres:13.13

* Attempt 2, postgres:13 instead of 13.13 and custom options
2024-03-26 22:44:07 +00:00
Crowdin Bot b70b5a5449 New Crowdin translations by Github Action 2024-03-26 21:15:05 +00:00
Crowdin Bot cc5dfb06bd New Crowdin translations by Github Action 2024-03-26 21:11:19 +00:00
191 changed files with 5673 additions and 505 deletions
+6 -1
View File
@@ -345,4 +345,9 @@ SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
+1 -1
View File
@@ -3,7 +3,7 @@ description: "Cache or restore if necessary"
inputs:
DATABASE_URL:
required: false
default: "postgresql://postgres:@localhost:5432/calendso"
default: "postgresql://postgres:postgres@localhost:5432/calendso"
path:
required: false
default: "backups/backup.sql"
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mailhog:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
strategy:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mailhog:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mailhog:
@@ -30,6 +30,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
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 }}
jobs:
build:
+9 -1
View File
@@ -34,6 +34,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
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 }}
jobs:
build:
@@ -42,13 +44,19 @@ jobs:
timeout-minutes: 30
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
+1 -1
View File
@@ -56,7 +56,7 @@ test.describe("Org", () => {
];
const response = await page.goto(`http://i.cal.com/${users[0].username}+${users[1].username}`);
expect(response?.status()).toBe(200);
expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Dynamic");
expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Group Meeting");
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[0].name);
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[1].name);
+1
View File
@@ -4,3 +4,4 @@ NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
# Get it in console.cal.com
CALCOM_LICENSE_KEY=""
NEXT_PUBLIC_API_V2_ROOT_URL=http://localhost:5555
+16
View File
@@ -47,6 +47,22 @@ const nextConfig = {
source: "/v:version/:rest*",
destination: "/api/v:version/:rest*",
},
{
source: "/api/v2",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`,
},
{
source: "/api/v2/health",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`,
},
{
source: "/api/v2/docs/:path*",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/docs/:path*`,
},
{
source: "/api/v2/:path*",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/api/v2/:path*`,
},
// This redirects requests to api/v*/ to /api/ passing version as a query parameter.
{
source: "/api/v:version/:rest*",
+1 -1
View File
@@ -7,7 +7,7 @@ LOG_LEVEL=
NEXTAUTH_SECRET=
DATABASE_URL=
JWT_SECRET=
SENTRY_DNS=
SENTRY_DSN=
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN=
+1 -1
View File
@@ -7,7 +7,7 @@
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "dtoFileNameSuffix": ".input.ts", "classValidatorShim": true }
"options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true }
}
]
}
+2 -2
View File
@@ -44,9 +44,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
})
);
if (process.env.SENTRY_DNS) {
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: getEnv("SENTRY_DNS"),
dsn: getEnv("SENTRY_DSN"),
});
}
@@ -0,0 +1,236 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
handleNewBooking,
getBookingInfo,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
describe("User Authenticated", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule],
providers: [SchedulesRepository, SchedulesService],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should create a booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {};
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toBeDefined();
expect(responseBody.data.user.email).toEqual(userEmail);
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.uid).toBeDefined();
expect(responseBody.data.startTime).toEqual(bookingStart);
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
expect(responseBody.data.metadata).toEqual(bookingMetadata);
createdBooking = responseBody.data;
});
});
it("should get bookings", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/bookings")
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getAllUserBookings>>> =
response.body;
const fetchedBooking = responseBody.data.bookings[0];
expect(responseBody.data.bookings.length).toEqual(1);
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(fetchedBooking).toBeDefined();
expect(fetchedBooking.id).toEqual(createdBooking.id);
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
expect(fetchedBooking.user?.email).toEqual(createdBooking.user.email);
});
});
it("should get booking", async () => {
return request(app.getHttpServer())
.get(`/api/v2/ee/bookings/${createdBooking.uid}`)
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getBookingInfo>>> = response.body;
const { bookingInfo } = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(bookingInfo?.id).toBeDefined();
expect(bookingInfo?.uid).toBeDefined();
expect(bookingInfo?.id).toEqual(createdBooking.id);
expect(bookingInfo?.uid).toEqual(createdBooking.uid);
expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(bookingInfo?.startTime).toEqual(createdBooking.startTime);
});
});
it("should create a recurring booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingRecurringCount = 5;
const currentBookingRecurringIndex = 0;
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
recurringCount: bookingRecurringCount,
currentRecurringIndex: currentBookingRecurringIndex,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/reccuring")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleNewRecurringBooking>>> =
response.body;
expect(responseBody.status).toEqual("recurring");
});
});
it("should create an instant booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/instant")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>> = response.body;
expect(responseBody.status).toEqual("instant");
});
});
it("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
allRemainingBookings: false,
cancellationReason: "Was fighting some unforseen rescheduling demons",
};
return request(app.getHttpServer())
.post(`/api/v2/ee/bookings/${bookingId}/cancel`)
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> =
response.body;
expect(bookingId).toBeDefined();
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1,8 +1,11 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input";
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 { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
Controller,
Post,
@@ -11,12 +14,23 @@ import {
InternalServerErrorException,
Body,
HttpException,
Param,
Get,
Query,
NotFoundException,
UseGuards,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries";
import {
handleNewBooking,
BookingResponse,
@@ -24,7 +38,9 @@ import {
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "ee/bookings",
@@ -34,7 +50,65 @@ import { ApiResponse } from "@calcom/platform-types";
export class BookingsController {
private readonly logger = new Logger("ee bookings controller");
constructor(private readonly oAuthFlowService: OAuthFlowService) {}
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService
) {}
// note(Rajiv): currently this endpoint is atoms only
@Get("/")
@UseGuards(AccessTokenGuard)
async getBookings(
@GetUser() user: User,
@Query() queryParams: GetBookingsInput
): Promise<ApiResponse<unknown>> {
const { filters, cursor, limit } = queryParams;
const bookings = await getAllUserBookings({
bookingListingByStatus: filters.status,
skip: cursor ?? 0,
take: limit ?? 10,
filters,
ctx: {
user: { email: user.email, id: user.id },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
},
});
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid")
async getBooking(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
return {
status: SUCCESS_STATUS,
data: bookingInfo,
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid/reschedule")
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/")
@Permissions([BOOKING_WRITE])
@@ -56,6 +130,30 @@ export class BookingsController {
throw new InternalServerErrorException("Could not create booking.");
}
@Post("/:bookingId/cancel")
@Permissions([BOOKING_WRITE])
async cancelBooking(
@Req() req: Request & { userId?: number },
@Param("bookingId") bookingId: string,
@Body() body: CancelBookingInput
): Promise<ApiResponse> {
if (bookingId) {
req.userId = await this.getOwnerId(req);
req.body = { ...body, id: parseInt(bookingId) };
try {
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
return {
status: SUCCESS_STATUS,
};
} catch (err) {
handleBookingErrors(err);
}
} else {
throw new NotFoundException("Booking ID is required.");
}
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/reccuring")
@Permissions([BOOKING_WRITE])
async createReccuringBooking(
@@ -1,6 +1,8 @@
import { bootstrap } from "@/app";
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 { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -61,7 +63,7 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const name = "bob";
const name = "bob the builder";
const username = name;
let eventType: EventType;
let user: User;
@@ -86,6 +88,7 @@ describe("Event types Endpoints", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -95,17 +98,6 @@ describe("Event types Endpoints", () => {
username,
});
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
eventType = await eventTypesRepositoryFixture.create(
{
length: 60,
title: "peer coding session",
slug: "peer-coding",
},
user.id
);
await app.init();
});
@@ -129,6 +121,50 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined();
});
it("should create an event type", async () => {
const body: CreateEventTypeInput = {
title: "Test Event Type",
slug: "test-event-type",
description: "A description of the test event type.",
length: 60,
hidden: false,
locations: [
{
type: "Online",
link: "https://example.com/meet",
displayLocationPublicly: true,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data.title).toEqual(body.title);
eventType = responseBody.data;
});
});
it("should update event type", async () => {
const newTitle = "Updated title";
const body: UpdateEventTypeInput = {
title: newTitle,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async () => {
eventType.title = newTitle;
});
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -154,10 +190,8 @@ describe("Event types Endpoints", () => {
.expect(200);
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
@@ -178,7 +212,6 @@ describe("Event types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(eventType.id);
@@ -192,10 +225,18 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await eventTypesRepositoryFixture.delete(eventType.id);
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
@@ -1,4 +1,6 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -6,12 +8,29 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common";
import {
Controller,
UseGuards,
Get,
Param,
Post,
Body,
NotFoundException,
Patch,
HttpCode,
HttpStatus,
Delete,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EventType } from "@prisma/client";
import { EventTypesByViewer } from "@calcom/lib";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventType as AtomEventType, EventTypesPublic } from "@calcom/platform-libraries";
import type {
EventType as AtomEventType,
EventTypesPublic,
UpdateEventTypeReturn,
} from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
@@ -20,6 +39,7 @@ import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
version: "2",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
@@ -29,8 +49,8 @@ export class EventTypesController {
async createEventType(
@Body() body: CreateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<EventType>> {
const eventType = await this.eventTypesService.createUserEventType(user.id, body);
): Promise<CreateEventTypeOutput> {
const eventType = await this.eventTypesService.createUserEventType(user, body);
return {
status: SUCCESS_STATUS,
@@ -89,4 +109,36 @@ export class EventTypesController {
data: eventTypes,
};
}
@Patch("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.OK)
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Body() body: UpdateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<UpdateEventTypeReturn["eventType"]>> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Delete("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
): Promise<ApiResponse<EventType>> {
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
}
@@ -3,12 +3,13 @@ import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule],
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
providers: [EventTypesRepository, EventTypesService],
controllers: [EventTypesController],
exports: [EventTypesService, EventTypesRepository],
@@ -10,7 +10,10 @@ import { getEventTypeById } from "@calcom/platform-libraries";
export class EventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
async createUserEventType(
userId: number,
body: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">
) {
return this.dbWrite.prisma.eventType.create({
data: {
...body,
@@ -59,4 +62,19 @@ export class EventTypesRepository {
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } });
}
async getUserEventTypeBySlug(userId: number, slug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
userId_slug: {
userId: userId,
slug: slug,
},
},
});
}
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
}
@@ -1,13 +1,53 @@
import { IsNumber, IsString, Min } from "class-validator";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator";
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!";
export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
"Discover the culinary wonders of the Argentina by making the best flan ever!";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class CreateEventTypeInput {
@IsNumber()
@Min(1)
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsOptional()
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
hidden?: boolean;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations?: EventTypeLocation[];
// @ApiHideProperty()
// @IsOptional()
// @IsNumber()
// teamId?: number;
// @ApiHideProperty()
// @IsOptional()
// @IsEnum(SchedulingType)
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
}
@@ -0,0 +1,7 @@
export enum Editable {
system = "system",
systemButOptional = "system-but-optional",
systemButHidden = "system-but-hidden",
user = "user",
userReadonly = "user-readonly",
}
@@ -0,0 +1,16 @@
export enum BaseField {
number = "number",
boolean = "boolean",
address = "address",
name = "name",
text = "text",
textarea = "textarea",
email = "email",
phone = "phone",
multiemail = "multiemail",
select = "select",
multiselect = "multiselect",
checkbox = "checkbox",
radio = "radio",
radioInput = "radioInput",
}
@@ -0,0 +1,9 @@
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
SECONDLY = 6,
}
@@ -0,0 +1,5 @@
export enum PeriodType {
UNLIMITED = "UNLIMITED",
ROLLING = "ROLLING",
RANGE = "RANGE",
}
@@ -0,0 +1,5 @@
export enum SchedulingType {
ROUND_ROBIN = "ROUND_ROBIN",
COLLECTIVE = "COLLECTIVE",
MANAGED = "MANAGED",
}
@@ -0,0 +1,41 @@
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class EventTypeLocation {
@IsString()
@DocsProperty({ example: "link" })
type!: string;
@IsOptional()
@IsString()
@ApiHideProperty()
address?: string;
@IsOptional()
@IsUrl()
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
link?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
displayLocationPublicly?: boolean;
@IsOptional()
@IsString()
@ApiHideProperty()
hostPhoneNumber?: string;
@IsOptional()
@IsNumber()
@ApiHideProperty()
credentialId?: number;
@IsOptional()
@IsString()
@ApiHideProperty()
teamName?: string;
}
@@ -0,0 +1,412 @@
import { Editable } from "@/ee/event-types/inputs/enums/editable";
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
import { Frequency } from "@/ee/event-types/inputs/enums/frequency";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { Type } from "class-transformer";
import {
IsString,
IsBoolean,
IsOptional,
ValidateNested,
Min,
IsInt,
IsEnum,
IsArray,
IsDate,
IsNumber,
} from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
class Option {
@IsString()
value!: string;
@IsString()
label!: string;
}
class Source {
@IsString()
id!: string;
@IsString()
type!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
editUrl?: string;
@IsOptional()
@IsBoolean()
fieldRequired?: boolean;
}
class View {
@IsString()
id!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
description?: string;
}
class OptionsInput {
@IsString()
type!: "address" | "text" | "phone";
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
placeholder?: string;
}
class VariantField {
@IsString()
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
}
class Variant {
@ValidateNested({ each: true })
@Type(() => VariantField)
fields!: VariantField[];
}
class VariantsConfig {
variants!: Record<string, Variant>;
}
export class BookingField {
@IsEnum(BaseField)
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Option)
options?: Option[];
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
defaultLabel?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
getOptionsAt?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OptionsInput)
optionsInputs?: Record<string, OptionsInput>;
@IsOptional()
@IsString()
variant?: string;
@IsOptional()
@ValidateNested()
@Type(() => VariantsConfig)
variantsConfig?: VariantsConfig;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => View)
views?: View[];
@IsOptional()
@IsBoolean()
hideWhenJustOneOption?: boolean;
@IsOptional()
@IsBoolean()
hidden?: boolean;
@IsOptional()
@IsEnum(Editable)
editable?: Editable;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Source)
sources?: Source[];
}
export class RecurringEvent {
@IsDate()
@IsOptional()
dtstart?: Date;
@IsInt()
interval!: number;
@IsInt()
count!: number;
@IsEnum(Frequency)
freq!: Frequency;
@IsDate()
@IsOptional()
until?: Date;
@IsString()
@IsOptional()
tzid?: string;
}
export class IntervalLimits {
@IsNumber()
@IsOptional()
PER_DAY?: number;
@IsNumber()
@IsOptional()
PER_WEEK?: number;
@IsNumber()
@IsOptional()
PER_MONTH?: number;
@IsNumber()
@IsOptional()
PER_YEAR?: number;
}
export class UpdateEventTypeInput {
@IsInt()
@Min(1)
@IsOptional()
length?: number;
@IsString()
@IsOptional()
slug?: string;
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
hidden?: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsOptional()
locations?: EventTypeLocation[];
// @IsInt()
// @IsOptional()
// position?: number;
// @IsInt()
// @IsOptional()
// offsetStart?: number;
// @IsInt()
// @IsOptional()
// userId?: number;
// @IsInt()
// @IsOptional()
// profileId?: number;
// @IsInt()
// @IsOptional()
// teamId?: number;
// @IsString()
// @IsOptional()
// eventName?: string;
// @IsInt()
// @IsOptional()
// parentId?: number;
// @IsOptional()
// @IsArray()
// @ValidateNested({ each: true })
// @Type(() => BookingField)
// bookingFields?: BookingField[];
// @IsString()
// @IsOptional()
// timeZone?: string;
// @IsEnum(PeriodType)
// @IsOptional()
// periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
// @IsDate()
// @IsOptional()
// periodStartDate?: Date;
// @IsDate()
// @IsOptional()
// periodEndDate?: Date;
// @IsInt()
// @IsOptional()
// periodDays?: number;
// @IsBoolean()
// @IsOptional()
// periodCountCalendarDays?: boolean;
// @IsBoolean()
// @IsOptional()
// lockTimeZoneToggleOnBookingPage?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresConfirmation?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresBookerEmailVerification?: boolean;
// @ValidateNested()
// @Type(() => RecurringEvent)
// @IsOptional()
// recurringEvent?: RecurringEvent;
// @IsBoolean()
// @IsOptional()
// disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
// hideCalendarNotes?: boolean;
// @IsInt()
// @Min(0)
// @IsOptional()
// minimumBookingNotice?: number;
// @IsInt()
// @IsOptional()
// beforeEventBuffer?: number;
// @IsInt()
// @IsOptional()
// afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
// seatsPerTimeSlot?: number;
// @IsBoolean()
// @IsOptional()
// onlyShowFirstAvailableSlot?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAttendees?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAvailabilityCount?: boolean;
// @IsEnum(SchedulingType)
// @IsOptional()
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
// @IsInt()
// @IsOptional()
// scheduleId?: number;
// @IsInt()
// @IsOptional()
// price?: number;
// @IsString()
// @IsOptional()
// currency?: string;
// @IsInt()
// @IsOptional()
// slotInterval?: number;
// @IsString()
// @IsOptional()
// @IsUrl()
// successRedirectUrl?: string;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// bookingLimits?: IntervalLimits;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// durationLimits?: IntervalLimits;
// @IsBoolean()
// @IsOptional()
// isInstantEvent?: boolean;
// @IsBoolean()
// @IsOptional()
// assignAllTeamMembers?: boolean;
// @IsBoolean()
// @IsOptional()
// useEventTypeDestinationCalendarEmail?: boolean;
// @IsInt()
// @IsOptional()
// secondaryEmailId?: number;
}
@@ -0,0 +1,20 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => EventTypeOutput)
data!: EventTypeOutput;
}
@@ -0,0 +1,227 @@
import {
CREATE_EVENT_DESCRIPTION_EXAMPLE,
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import {
BookingField,
IntervalLimits,
RecurringEvent,
} from "@/ee/event-types/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsInt,
IsJSON,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export class EventTypeOutput {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description!: string | null;
@IsBoolean()
@ApiHideProperty()
hidden!: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations!: EventTypeLocation | null;
@IsInt()
@ApiHideProperty()
position!: number;
@IsInt()
@ApiHideProperty()
offsetStart!: number;
@IsInt()
@ApiHideProperty()
userId!: number | null;
@IsInt()
@ApiHideProperty()
profileId!: number | null;
@IsInt()
@ApiHideProperty()
teamId!: number | null;
@IsString()
@ApiHideProperty()
eventName!: string | null;
@IsInt()
@ApiHideProperty()
parentId!: number | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
@ApiHideProperty()
bookingFields!: BookingField[] | null;
@IsString()
@ApiHideProperty()
timeZone!: string | null;
@IsEnum(PeriodType)
@ApiHideProperty()
periodType!: PeriodType | null;
@IsDate()
@ApiHideProperty()
periodStartDate!: Date | null;
@IsDate()
@ApiHideProperty()
periodEndDate!: Date | null;
@IsInt()
@ApiHideProperty()
periodDays!: number | null;
@IsBoolean()
@ApiHideProperty()
periodCountCalendarDays!: boolean | null;
@IsBoolean()
@ApiHideProperty()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresConfirmation!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresBookerEmailVerification!: boolean;
@ValidateNested()
@Type(() => RecurringEvent)
@IsOptional()
@ApiHideProperty()
recurringEvent!: RecurringEvent | null;
@IsBoolean()
@ApiHideProperty()
disableGuests!: boolean;
@IsBoolean()
@ApiHideProperty()
hideCalendarNotes!: boolean;
@IsInt()
@ApiHideProperty()
minimumBookingNotice!: number;
@IsInt()
@ApiHideProperty()
beforeEventBuffer!: number;
@IsInt()
@ApiHideProperty()
afterEventBuffer!: number;
@IsInt()
@ApiHideProperty()
seatsPerTimeSlot!: number | null;
@IsBoolean()
@ApiHideProperty()
onlyShowFirstAvailableSlot!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAttendees!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAvailabilityCount!: boolean;
@IsEnum(SchedulingType)
@ApiHideProperty()
schedulingType!: SchedulingType | null;
@IsInt()
@ApiHideProperty()
scheduleId!: number | null;
@IsNumber()
@ApiHideProperty()
price!: number;
@IsString()
@ApiHideProperty()
currency!: string;
@IsInt()
@ApiHideProperty()
slotInterval!: number | null;
@IsJSON()
@ApiHideProperty()
metadata!: Record<string, any> | null;
@IsString()
@ApiHideProperty()
successRedirectUrl!: string | null;
@ValidateNested()
@Type(() => IntervalLimits)
@IsOptional()
@ApiHideProperty()
bookingLimits!: IntervalLimits;
@ValidateNested()
@Type(() => IntervalLimits)
@ApiHideProperty()
durationLimits!: IntervalLimits;
@IsBoolean()
@ApiHideProperty()
isInstantEvent!: boolean;
@IsBoolean()
@ApiHideProperty()
assignAllTeamMembers!: boolean;
@IsBoolean()
@ApiHideProperty()
useEventTypeDestinationCalendarEmail!: boolean;
@IsInt()
@ApiHideProperty()
secondaryEmailId!: number | null;
}
@@ -1,26 +1,73 @@
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 { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypesService {
constructor(
private readonly eventTypesRepository: EventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
return this.eventTypesRepository.createUserEventType(userId, body);
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { eventType } = await createEventType({
input: body,
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return eventType;
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
}
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
metadata: user.metadata,
};
}
async getUserEventType(userId: number, eventTypeId: number) {
return this.eventTypesRepository.getUserEventType(userId, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(userId, eventType);
return eventType;
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
@@ -30,7 +77,18 @@ export class EventTypesService {
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
return this.eventTypesRepository.getUserEventTypeForAtom(user, isUserOrganizationAdmin, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
user,
isUserOrganizationAdmin,
eventTypeId
);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -58,4 +116,51 @@ export class EventTypesService {
return defaultEventTypes;
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const { eventType } = await updateEventType({
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return eventType;
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
const existingEventType = await this.getUserEventType(userId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.checkUserOwnsEventType(userId, existingEventType);
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
async deleteEventType(eventTypeId: number, userId: number) {
const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
}
this.checkUserOwnsEventType(userId, existingEventType);
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
checkUserOwnsEventType(userId: number, eventType: Pick<EventType, "id" | "userId">) {
if (userId !== eventType.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
}
}
}
@@ -82,16 +82,16 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined();
});
it(`/GET/platform/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/platform/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
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/platform/gcal/oauth/auth-url`)
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
@@ -99,50 +99,50 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/platform/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/platform/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`
`/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`
)
.expect(400);
});
it(`/GET/platform/gcal/oauth/save: without access token`, async () => {
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/platform/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`
`/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`
)
.expect(400);
});
it(`/GET/platform/gcal/oauth/save: without origin`, async () => {
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/platform/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`
`/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`
)
.expect(400);
});
it(`/GET/platform/gcal/check with access token but without origin`, async () => {
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`)
.get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/platform/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/platform/gcal/check`).expect(401);
it(`/GET/ee/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/ee/gcal/check`).expect(401);
});
it(`/GET/platform/gcal/check with access token and origin but no credentials`, async () => {
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`)
.get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
});
it(`/GET/platform/gcal/check with access token and origin and gcal credentials`, async () => {
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
@@ -150,7 +150,7 @@ describe("Platform Gcal Endpoints", () => {
"google-calendar"
);
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`)
.get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
+2 -2
View File
@@ -40,7 +40,7 @@ const CALENDAR_SCOPES = [
];
@Controller({
path: "platform/gcal",
path: "ee/gcal",
version: "2",
})
export class GcalController {
@@ -55,7 +55,7 @@ export class GcalController {
private readonly gcalService: GcalService
) {}
private redirectUri = `${this.config.get("api.url")}/platform/gcal/oauth/save`;
private redirectUri = `${this.config.get("api.url")}/ee/gcal/oauth/save`;
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@@ -65,7 +65,7 @@ describe("Me Endpoints", () => {
it("should get user associated with access token", async () => {
return request(app.getHttpServer())
.get("/api/v2/me")
.get("/api/v2/ee/me")
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
@@ -84,7 +84,7 @@ describe("Me Endpoints", () => {
const body: UpdateManagedPlatformUserInput = { timeZone: "Europe/Rome" };
return request(app.getHttpServer())
.patch("/api/v2/me")
.patch("/api/v2/ee/me")
.send(body)
.expect(200)
.then(async (response) => {
@@ -108,19 +108,22 @@ describe("Me Endpoints", () => {
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedPlatformUserInput = { timeZone: "Narnia/Woods" };
return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectTimeZone).expect(400);
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectTimeZone).expect(400);
});
it("should not update user associated with access token given invalid time format", async () => {
const bodyWithIncorrectTimeFormat: UpdateManagedPlatformUserInput = { timeFormat: 100 };
return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
return request(app.getHttpServer())
.patch("/api/v2/ee/me")
.send(bodyWithIncorrectTimeFormat)
.expect(400);
});
it("should not update user associated with access token given invalid week start", async () => {
const bodyWithIncorrectWeekStart: UpdateManagedPlatformUserInput = { weekStart: "waba luba dub dub" };
return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectWeekStart).expect(400);
});
afterAll(async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import { UserResponse, userSchemaResponse } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "me",
path: "ee/me",
version: "2",
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@@ -20,7 +20,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "platform/provider",
path: "ee/provider",
version: "2",
})
export class CalProviderController {
@@ -21,7 +21,6 @@ import {
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ScheduleWithAvailabilitiesForWeb } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
import { updateScheduleHandler } from "@calcom/platform-libraries";
import type { UpdateScheduleOutputType } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types";
@@ -73,16 +72,6 @@ export class SchedulesController {
};
}
@Get("/time-zones")
async getTimeZones(): Promise<ApiResponse<CityTimezones>> {
const timeZones = await this.schedulesService.getSchedulePossibleTimeZones();
return {
status: SUCCESS_STATUS,
data: timeZones,
};
}
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
async getSchedule(
@@ -6,8 +6,6 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
@Injectable()
export class SchedulesService {
constructor(
@@ -96,8 +94,4 @@ export class SchedulesService {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
}
}
async getSchedulePossibleTimeZones() {
return cityTimezonesHandler();
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ export type Environment = {
NEXTAUTH_SECRET: string;
DATABASE_URL: string;
JWT_SECRET: string;
SENTRY_DNS: string;
SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
};
@@ -14,6 +14,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const statusCode = exception.getStatus();
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
request,
});
response.status(statusCode).json({
status: ERROR_STATUS,
@@ -34,6 +34,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const request = ctx.getRequest();
this.logger.error(`PrismaError: ${error.message}`, {
error,
request,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS,
@@ -16,6 +16,7 @@ export class SentryFilter extends BaseExceptionFilter {
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
exception,
request,
});
// capture if client has been init
@@ -40,6 +40,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
}
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
request,
});
response.status(statusCode).json({
status: ERROR_STATUS,
@@ -15,6 +15,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const request = ctx.getRequest();
this.logger.error(`ZodError: ${error.message}`, {
error,
request,
});
response.status(HttpStatus.BAD_REQUEST).json({
status: ERROR_STATUS,
+2 -1
View File
@@ -1,11 +1,12 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { EventsModule } from "@/modules/events/events.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],
imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -1,9 +1,10 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [],
imports: [PrismaModule],
providers: [SelectedCalendarsRepository],
exports: [SelectedCalendarsRepository],
})
export class CredentialsModule {}
export class SelectedCalendarsModule {}
@@ -16,4 +16,12 @@ export class SelectedCalendarsRepository {
},
});
}
getUserSelectedCalendars(userId: number) {
return this.dbRead.prisma.selectedCalendar.findMany({
where: {
userId,
},
});
}
}
@@ -0,0 +1,24 @@
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Controller, Get } from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "timezones",
version: "2",
})
export class TimezonesController {
constructor(private readonly timezonesService: TimezonesService) {}
@Get("/")
async getTimeZones(): Promise<ApiResponse<CityTimezones>> {
const timeZones = await this.timezonesService.getCityTimeZones();
return {
status: SUCCESS_STATUS,
data: timeZones,
};
}
}
@@ -0,0 +1,10 @@
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
@Injectable()
export class TimezonesService {
async getCityTimeZones() {
return cityTimezonesHandler();
}
}
@@ -0,0 +1,11 @@
import { TimezonesController } from "@/modules/timezones/controllers/timezones.controller";
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Module } from "@nestjs/common";
@Module({
imports: [],
providers: [TimezonesService],
controllers: [TimezonesController],
exports: [TimezonesService],
})
export class TimezoneModule {}
@@ -48,7 +48,7 @@ export class TokensRepository {
}
async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate();
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([
this.dbWrite.prisma.accessToken.create({
@@ -21,7 +21,7 @@ export class UsersRepository {
) {
this.formatInput(user);
return this.dbRead.prisma.user.create({
return this.dbWrite.prisma.user.create({
data: {
...user,
username,
@@ -34,7 +34,7 @@ export class UsersRepository {
}
async addToOAuthClient(userId: number, oAuthClientId: string) {
return this.dbRead.prisma.user.update({
return this.dbWrite.prisma.user.update({
data: {
platformOAuthClients: {
connect: { id: oAuthClientId },
+385 -47
View File
@@ -22,7 +22,40 @@
"/api/v2/events/public": {
"get": {
"operationId": "EventsController_getPublicEvent",
"parameters": [],
"parameters": [
{
"name": "username",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "eventSlug",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "isTeamEvent",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "org",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
@@ -55,7 +88,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserInput"
"$ref": "#/components/schemas/CreateManagedPlatformUserInput"
}
}
}
@@ -133,7 +166,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUserInput"
"$ref": "#/components/schemas/UpdateManagedPlatformUserInput"
}
}
}
@@ -500,12 +533,15 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/CreateEventTypeOutput"
}
}
}
}
}
},
"tags": [
"Event types"
]
},
"get": {
"operationId": "EventTypesController_getEventTypes",
@@ -521,7 +557,10 @@
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/event-types/{eventTypeId}": {
@@ -548,7 +587,76 @@
}
}
}
}
},
"tags": [
"Event types"
]
},
"patch": {
"operationId": "EventTypesController_updateEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateEventTypeInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Event types"
]
},
"delete": {
"operationId": "EventTypesController_deleteEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/event-types/{username}/public": {
@@ -575,10 +683,13 @@
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/platform/gcal/oauth/auth-url": {
"/api/v2/ee/gcal/oauth/auth-url": {
"get": {
"operationId": "GcalController_redirect",
"parameters": [
@@ -605,7 +716,7 @@
}
}
},
"/api/v2/platform/gcal/oauth/save": {
"/api/v2/ee/gcal/oauth/save": {
"get": {
"operationId": "GcalController_save",
"parameters": [
@@ -640,7 +751,7 @@
}
}
},
"/api/v2/platform/gcal/check": {
"/api/v2/ee/gcal/check": {
"get": {
"operationId": "GcalController_check",
"parameters": [],
@@ -658,7 +769,7 @@
}
}
},
"/api/v2/platform/provider/{clientId}": {
"/api/v2/ee/provider/{clientId}": {
"get": {
"operationId": "CalProviderController_verifyClientId",
"parameters": [
@@ -685,7 +796,7 @@
}
}
},
"/api/v2/platform/provider/{clientId}/access-token": {
"/api/v2/ee/provider/{clientId}/access-token": {
"get": {
"operationId": "CalProviderController_verifyAccessToken",
"parameters": [
@@ -774,24 +885,6 @@
}
}
},
"/api/v2/schedules/time-zones": {
"get": {
"operationId": "SchedulesController_getTimeZones",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/schedules/{scheduleId}": {
"get": {
"operationId": "SchedulesController_getSchedule",
@@ -870,7 +963,7 @@
}
}
},
"/api/v2/me": {
"/api/v2/ee/me": {
"get": {
"operationId": "MeController_getMe",
"parameters": [],
@@ -895,7 +988,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUserInput"
"$ref": "#/components/schemas/UpdateManagedPlatformUserInput"
}
}
}
@@ -951,6 +1044,22 @@
}
},
"/api/v2/ee/bookings": {
"get": {
"operationId": "BookingsController_getBookings",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"post": {
"operationId": "BookingsController_createBooking",
"parameters": [],
@@ -978,6 +1087,97 @@
}
}
},
"/api/v2/ee/bookings/{bookingUid}": {
"get": {
"operationId": "BookingsController_getBooking",
"parameters": [
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/ee/bookings/{bookingUid}/reschedule": {
"get": {
"operationId": "BookingsController_getBookingForReschedule",
"parameters": [
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/ee/bookings/{bookingId}/cancel": {
"post": {
"operationId": "BookingsController_cancelBooking",
"parameters": [
{
"name": "bookingId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CancelBookingInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/ee/bookings/reccuring": {
"post": {
"operationId": "BookingsController_createReccuringBooking",
@@ -1100,6 +1300,24 @@
}
}
}
},
"/api/v2/timezones": {
"get": {
"operationId": "TimezonesController_getTimeZones",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
}
},
"info": {
@@ -1112,7 +1330,7 @@
"servers": [],
"components": {
"schemas": {
"CreateUserInput": {
"CreateManagedPlatformUserInput": {
"type": "object",
"properties": {
"email": {
@@ -1124,9 +1342,6 @@
"timeFormat": {
"type": "number"
},
"defaultScheduleId": {
"type": "number"
},
"weekStart": {
"type": "string"
},
@@ -1138,7 +1353,7 @@
"email"
]
},
"UpdateUserInput": {
"UpdateManagedPlatformUserInput": {
"type": "object",
"properties": {
"email": {
@@ -1187,11 +1402,11 @@
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
],
"example": "success"
},
"data": {
"example": {
@@ -1230,7 +1445,8 @@
"example": 3
},
"logo": {
"type": "object",
"type": "string",
"nullable": true,
"example": "https://example.com/logo.png"
},
"redirectUris": {
@@ -1393,7 +1609,118 @@
"refreshToken"
]
},
"EventTypeLocation": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "link"
},
"link": {
"type": "string",
"example": "https://masterchief.com/argentina/flan/video/9129412"
}
},
"required": [
"type"
]
},
"CreateEventTypeInput": {
"type": "object",
"properties": {
"length": {
"type": "number",
"minimum": 1,
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
}
},
"required": [
"length",
"slug",
"title"
]
},
"EventTypeOutput": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"length": {
"type": "number",
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"nullable": true,
"example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
"locations": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/EventTypeLocation"
}
]
}
},
"required": [
"id",
"length",
"slug",
"title",
"description",
"locations"
]
},
"CreateEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/EventTypeOutput"
}
},
"required": [
"status",
"data"
]
},
"UpdateEventTypeInput": {
"type": "object",
"properties": {
"length": {
@@ -1405,13 +1732,20 @@
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
}
},
"required": [
"length",
"slug",
"title"
]
}
},
"CreateAvailabilityInput": {
"type": "object",
@@ -1526,6 +1860,10 @@
"hashedLink"
]
},
"CancelBookingInput": {
"type": "object",
"properties": {}
},
"ReserveSlotInput": {
"type": "object",
"properties": {}
@@ -0,0 +1,26 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Booking, User } from "@prisma/client";
export class BookingsRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.primaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async getById(bookingId: Booking["id"]) {
return this.primaReadClient.booking.findFirst({ where: { id: bookingId } });
}
async deleteById(bookingId: Booking["id"]) {
return this.prismaWriteClient.booking.delete({ where: { id: bookingId } });
}
async deleteAllBookings(userId: User["id"], userEmail: User["email"]) {
return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } });
}
}
@@ -21,7 +21,7 @@ export class EventTypesRepositoryFixture {
});
}
async create(data: CreateEventTypeInput, userId: number) {
async create(data: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">, userId: number) {
return this.prismaWriteClient.eventType.create({
data: {
...data,
+1
View File
@@ -0,0 +1 @@
## Cal.com Platform Example (https://platform.cal.com)
@@ -122,7 +122,7 @@ export const InstallAppButtonChild = ({
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
key={team.id}
disabled={isInstalled}
StartIcon={(props) => (
StartIcon={(props: { className?: string }) => (
<Avatar
alt={team.logo || ""}
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
@@ -0,0 +1,279 @@
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Label,
EmptyScreen,
SettingsToggle,
Divider,
TextField,
TextAreaField,
PhoneInput,
showToast,
} from "@calcom/ui";
import { Sparkles } from "@calcom/ui/components/icon";
type AIEventControllerProps = {
eventType: EventTypeSetup;
isTeamEvent: boolean;
};
export default function AIEventController({ eventType, isTeamEvent }: AIEventControllerProps) {
const { t } = useLocale();
const session = useSession();
const [aiEventState, setAIEventState] = useState<boolean>(eventType?.aiPhoneCallConfig?.enabled ?? false);
const formMethods = useFormContext<FormValues>();
const isOrg = !!session.data?.user?.org?.id;
if (session.status === "loading") return <></>;
return (
<LicenseRequired>
<div className="block items-start sm:flex">
{!isOrg || !isTeamEvent ? (
<EmptyScreen
headline={t("Cal.ai")}
Icon={Sparkles}
description={t("upgrade_to_cal_ai_phone_number_description")}
buttonRaw={<Button href="/enterprise">{t("upgrade")}</Button>}
/>
) : (
<div className="w-full">
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
aiEventState && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("Cal.ai")}
description={t("use_cal_ai_to_make_call_description")}
checked={aiEventState}
data-testid="instant-event-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("aiPhoneCallConfig.enabled", false, {
shouldDirty: true,
});
setAIEventState(false);
} else {
formMethods.setValue("aiPhoneCallConfig.enabled", true, {
shouldDirty: true,
});
setAIEventState(true);
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{aiEventState && <AISettings eventType={eventType} />}
</div>
</SettingsToggle>
</div>
)}
</div>
</LicenseRequired>
);
}
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const [calApiKey, setCalApiKey] = useState("");
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
onSuccess: (data) => {
if (!!data?.call_id) {
showToast("Phone Call Created successfully", "success");
}
},
onError: (err) => {
showToast(t("something_went_wrong"), "error");
},
});
// const [createModalOpen, setCreateModalOpen] = useState(false);
// const NewPhoneButton = () => {
// const { t } = useLocale();
// return (
// <Button
// color="primary"
// data-testid="new_phone_number"
// StartIcon={Plus}
// onClick={() => setCreateModalOpen(true)}>
// {t("New Phone number")}
// </Button>
// );
// };
// v1 will require the user to log in to Retellai.com to create a phone number, and an agent and
// authorize it with the Cal.com API key / OAuth
// const retellAuthorized = true; // TODO: call retellAPI here
const handleSubmit = async () => {
try {
const values = formMethods.getValues("aiPhoneCallConfig");
const data = await AIPhoneSettingSchema.parseAsync({
...values,
eventTypeId: eventType.id,
calApiKey,
});
createCallMutation.mutate(data);
} catch (err) {
if (err instanceof z.ZodError) {
const fieldName = err.issues?.[0]?.path?.[0];
const message = err.issues?.[0]?.message;
showToast(`Error on ${fieldName}: ${message} `, "error");
} else {
showToast(t("something_went_wrong"), "error");
}
}
};
return (
<div>
<div className="space-y-4">
<>
<Label>{t("your_phone_number")}</Label>
<Controller
name="aiPhoneCallConfig.yourPhoneNumber"
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
placeholder={t("your_phone_number")}
id="aiPhoneCallConfig.yourPhoneNumber"
name="aiPhoneCallConfig.yourPhoneNumber"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
);
}}
/>
<Label>{t("number_to_call")}</Label>
<Controller
name="aiPhoneCallConfig.numberToCall"
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
placeholder={t("phone_number")}
id="aiPhoneCallConfig.numberToCall"
name="aiPhoneCallConfig.numberToCall"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
);
}}
/>
<Divider />
</>
<TextField
type="text"
hint="Variable: {name}"
label={t("guest_name")}
placeholder="Jane Doe"
{...formMethods.register("aiPhoneCallConfig.guestName")}
/>
<TextField
type="text"
hint="For eg:- cal_live_0123.."
label={t("provide_api_key")}
name="calApiKey"
placeholder="Cal API Key"
value={calApiKey}
onChange={(e) => {
setCalApiKey(e.target.value);
}}
/>
<Divider />
<TextAreaField
rows={3}
required
placeholder={t("general_prompt")}
label={t("general_prompt")}
{...formMethods.register("aiPhoneCallConfig.generalPrompt")}
onChange={(e) => {
formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true });
}}
/>
<TextAreaField
rows={3}
placeholder={t("begin_message")}
label={t("begin_message")}
{...formMethods.register("aiPhoneCallConfig.beginMessage")}
onChange={(e) => {
formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true });
}}
/>
<Button
disabled={createCallMutation.isPending}
loading={createCallMutation.isPending}
onClick={handleSubmit}>
{t("make_a_call")}
</Button>
{/* TODO:<small className="block opacity-60">
Want to automate outgoing phone calls? Read our{" "}
<Link className="underline" href="https://cal.com/docs">
API docs
</Link>{" "}
and learn how to build workflows.
</small> */}
</div>
{/* TODO:
<>
<EmptyScreen
Icon={Phone}
headline={t("Create your phone number")}
description={t(
"This phone number can be called by guests but can also do proactive outbound calls by the AI agent."
)}
buttonRaw={
<div className="flex justify-between gap-2">
<NewPhoneButton />
<Button color="secondary">{t("learn_more")}</Button>
</div>
}
/>
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
<DialogContent
enableOverflow
title={t("Create phone number")}
description={t("This number can later be called or can do proactive outbound calls")}>
<div className="mb-12 mt-4">
<TextField placeholder="+415" hint="Area Code" />
</div>
</DialogContent>
</Dialog>
</>
*/}
</div>
);
};
@@ -0,0 +1,10 @@
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import AIEventController from "./AIEventController";
export const EventAITab = ({
eventType,
isTeamEvent,
}: Pick<EventTypeSetupProps, "eventType"> & { isTeamEvent: boolean }) => {
return <AIEventController eventType={eventType} isTeamEvent={isTeamEvent} />;
};
@@ -126,7 +126,7 @@ export const EventSetupTab = (
});
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 480,
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
@@ -44,6 +44,7 @@ import {
Repeat,
Grid,
Zap,
Sparkles,
Users,
ExternalLink,
Code,
@@ -283,6 +284,15 @@ function EventTypeSingleLayout({
info: `${activeWebhooksNumber} ${t("active")}`,
});
}
const hidden = true; // hidden while in alpha trial. you can access it with tabName=ai
if (team && hidden) {
navigation.push({
name: "Cal.ai",
href: `/event-types/${eventType.id}?tabName=ai`,
icon: Sparkles,
info: "cal_ai_event_tab_description", // todo `cal_ai_event_tab_description`,
});
}
return navigation;
}, [
t,
+3 -1
View File
@@ -1,9 +1,11 @@
import React from "react";
import type { LucideIcon } from "@calcom/ui/components/icon";
import type { SVGComponent } from "@lib/types/SVGComponent";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: SVGComponent;
Icon: SVGComponent | LucideIcon;
}
export default function LinkIconButton(props: LinkIconButtonProps) {
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
@@ -62,58 +62,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const maybeBookingUidFromSeat = await maybeGetBookingUidFromSeat(prisma, uid);
if (maybeBookingUidFromSeat.uid) uid = maybeBookingUidFromSeat.uid;
if (maybeBookingUidFromSeat.seatReferenceUid) seatReferenceUid = maybeBookingUidFromSeat.seatReferenceUid;
const bookingInfoRaw = await prisma.booking.findFirst({
where: {
uid: uid,
},
select: {
title: true,
id: true,
uid: true,
description: true,
customInputs: true,
smsReminderNumber: true,
recurringEventId: true,
startTime: true,
endTime: true,
location: true,
status: true,
metadata: true,
cancellationReason: true,
responses: true,
rejectionReason: true,
userPrimaryEmail: true,
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
},
},
attendees: {
select: {
name: true,
email: true,
timeZone: true,
},
},
eventTypeId: true,
eventType: {
select: {
eventName: true,
slug: true,
timeZone: true,
},
},
seatsReferences: {
select: {
referenceUid: true,
},
},
},
});
const { bookingInfoRaw, bookingInfo } = await getBookingInfo(uid);
if (!bookingInfoRaw) {
return {
notFound: true,
@@ -133,7 +84,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
requiresLoginToUpdate = true;
}
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
@@ -34,6 +34,11 @@ import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayo
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
const DEFAULT_PROMPT_VALUE =
"## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.\n\n## Style Guardrails\nBe Concise: Respond succinctly, addressing one topic at most.\nEmbrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.\nBe Conversational: Use everyday language, making the chat feel like talking to a friend.\nBe Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.\nAvoid multiple questions in a single response.\nGet clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.\nUse a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).\nIf you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.\n\n## Response Guideline\nAdapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.\nStay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.\nEnsure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.\n\n## Schedule Rule\nCurrent time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.\n\n## Task Steps\n1. I am here to learn more about your issue and help schedule an appointment with our support team.\n2. Ask for user name and email. Confirm the name and email with user by reading it back to user.\n3. Ask user for \"When would you want to meet with one of our representive\".\n4. Call function check_availability to check for availability in the user provided time range.\n - if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.\n - if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.\n4. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".\n6. Once confirmed, call function book_appointment to book the appointment.\n - if booking returned booking detail, it means booking is successful, proceed to step 7.\n - if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.\n7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.\n8. After all questions answered, call function end_call to hang up.";
const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
const EventSetupTab = dynamic(() =>
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
@@ -73,6 +78,8 @@ const EventWebhooksTab = dynamic(() =>
import("@components/eventtype/EventWebhooksTab").then((mod) => mod.EventWebhooksTab)
);
const EventAITab = dynamic(() => import("@components/eventtype/EventAITab").then((mod) => mod.EventAITab));
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
export type Host = { isFixed: boolean; userId: number; priority: number };
@@ -92,6 +99,7 @@ const querySchema = z.object({
"advanced",
"workflows",
"webhooks",
"ai",
])
.optional()
.default("setup"),
@@ -243,6 +251,14 @@ const EventTypePage = (props: EventTypeSetupProps) => {
})),
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
assignAllTeamMembers: eventType.assignAllTeamMembers,
aiPhoneCallConfig: {
generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE,
enabled: eventType.aiPhoneCallConfig?.enabled,
beginMessage: eventType.aiPhoneCallConfig?.beginMessage ?? DEFAULT_BEGIN_MESSAGE,
guestName: eventType.aiPhoneCallConfig?.guestName,
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
},
};
}, [eventType, periodDates, metadata]);
const formMethods = useForm<FormValues>({
@@ -375,6 +391,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
/>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
} as const;
const isObject = <T,>(value: T): boolean => {
return value !== null && typeof value === "object" && !Array.isArray(value);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.9.2",
"version": "3.9.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -82,7 +82,7 @@ export const OAuthClients = () => {
) : (
<EmptyScreen
headline="Create your first OAuth client"
description="OAuth clients faciliate acceess to cal.com on users behalf"
description="OAuth clients facilitate access to Cal.com on behalf of users"
Icon={Plus}
className="rounded-b-lg rounded-t-none border-t-0"
buttonRaw={<NewOAuthClientButton />}
+2 -2
View File
@@ -11,7 +11,7 @@ test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Out of office", () => {
test("User can create out of office entry", async ({ page, users }) => {
test.skip("User can create out of office entry", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
await user.apiLogin();
@@ -23,7 +23,7 @@ test.describe("Out of office", () => {
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
});
test("User can configure booking redirect", async ({ page, users }) => {
test.skip("User can configure booking redirect", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
const userTo = await users.create({ name: "userTwo" });
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "قم بتأكيد كلمة المرور الحالية للبدء.",
"2fa_scan_image_or_use_code": "امسح الصورة أدناه ضوئيًا باستخدام تطبيق المصادقة على هاتفك، أو أدخل الرمز النصي يدويًا.",
"text": "النص",
"your_phone_number": "رقم هاتفك",
"multiline_text": "النص متعدد السطور",
"number": "الرقم",
"checkbox": "مربع الاختيار",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Pro pokračování zadejte vaše současné heslo.",
"2fa_scan_image_or_use_code": "Načtěte obrázek níž autentifikační aplikací na vašem telefonu nebo opište kód ručně.",
"text": "Text",
"your_phone_number": "Vaše telefonní číslo",
"multiline_text": "Víceřádkový text",
"number": "Číslo",
"checkbox": "Zaškrtávací pole",
@@ -151,6 +151,7 @@
"2fa_confirm_current_password": "Bekræft din nuværende adgangskode for at komme i gang.",
"2fa_scan_image_or_use_code": "Scan billedet nedenfor med autentificerings-appen på din telefon, eller indtast tekstkoden manuelt i stedet.",
"text": "Tekst",
"your_phone_number": "Dit Telefonnummer",
"multiline_text": "Flerlinje Tekst",
"number": "Nummer",
"checkbox": "Afkrydsningsfelt",
@@ -206,6 +206,7 @@
"2fa_confirm_current_password": "Bestätigen Sie Ihr aktuelles Passwort, um loszulegen.",
"2fa_scan_image_or_use_code": "Scannen Sie das Bild mit der Authentifizierungs-App auf Ihrem Telefon oder geben Sie stattdessen den Textcode manuell ein.",
"text": "Text",
"your_phone_number": "Ihre Telefonnummer",
"multiline_text": "Mehrzeiliger Text",
"number": "Nummer",
"checkbox": "Checkbox",
@@ -1513,7 +1514,7 @@
"report_app": "App melden",
"limit_booking_frequency": "Buchungsfrequenz begrenzen",
"limit_booking_frequency_description": "Begrenzen, wie oft dieses Ereignis gebucht werden kann",
"limit_booking_only_first_slot": "Buchun auf den ersten Platz beschränken",
"limit_booking_only_first_slot": "Buchung auf den ersten Platz beschränken",
"limit_booking_only_first_slot_description": "Nur die Buchung des ersten Platzes am Tag erlauben",
"limit_total_booking_duration": "Gesamtbuchungsdauer beschränken",
"limit_total_booking_duration_description": "Begrenzt die Gesamtanzahl, die dieser Termin gebucht werden kann",
@@ -90,6 +90,7 @@
"copy_public_page_link": "Αντιγραφή συνδέσμου δημόσιας σελίδας",
"sign_out": "Αποσύνδεση",
"2fa_confirm_current_password": "Επιβεβαιώστε τον κωδικό πρόσβασης σας για να ξεκινήσετε.",
"your_phone_number": "Ο αριθμός τηλεφώνου σας",
"required": "Υποχρεωτικό",
"optional": "Προαιρετικό",
"guests": "Επισκέπτες",
@@ -81,6 +81,8 @@
"payment": "Payment",
"missing_card_fields": "Missing card fields",
"pay_now": "Pay now",
"general_prompt": "General Prompt",
"begin_message":"Begin Message",
"codebase_has_to_stay_opensource": "The codebase has to stay open source, whether it was modified or not",
"cannot_repackage_codebase": "You can not repackage or sell the codebase",
"acquire_license": "Acquire a commercial license to remove these terms by emailing",
@@ -207,6 +209,7 @@
"2fa_confirm_current_password": "Confirm your current password to get started.",
"2fa_scan_image_or_use_code": "Scan the image below with the authenticator app on your phone or manually enter the text code instead.",
"text": "Text",
"your_phone_number":"Your Phone Number",
"multiline_text": "Multiline Text",
"number": "Number",
"checkbox": "Checkbox",
@@ -693,6 +696,7 @@
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minutes",
"use_cal_ai_to_make_call_description": "Use Cal.ai to get an AI powered phone number or make calls to guests.",
"round_robin": "Round Robin",
"round_robin_description": "Cycle meetings between multiple team members.",
"managed_event": "Managed Event",
@@ -1277,6 +1281,7 @@
"upgrade": "Upgrade",
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
"upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
"upgrade_to_cal_ai_phone_number_description":"Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls",
"recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan",
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
@@ -2249,6 +2254,8 @@
"troubleshooter_tooltip": "Open the troubleshooter and figure out what is wrong with your schedule",
"need_help": "Need help?",
"troubleshooter": "Troubleshooter",
"number_to_call": "Number to Call",
"guest_name": "Guest Name",
"please_install_a_calendar": "Please install a calendar",
"instant_tab_title": "Instant Booking",
"instant_event_tab_description": "Let people book immediately",
@@ -2256,6 +2263,7 @@
"dont_want_to_wait": "Don't want to wait?",
"meeting_started": "Meeting Started",
"pay_and_book": "Pay to book",
"cal_ai_event_tab_description":"Let AI Agents book you",
"booking_not_found_error": "Could not find booking",
"booking_seats_full_error": "Booking seats are full",
"missing_payment_credential_error": "Missing payment credentials",
@@ -2330,5 +2338,6 @@
"lock_org_users_eventtypes": "Lock individual event type creation",
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
"make_a_call": "Make a Call",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Confirma tu contraseña actual para empezar.",
"2fa_scan_image_or_use_code": "Escanee la imagen a continuación con la aplicación de autenticación en su teléfono o ingrese manualmente el código de texto.",
"text": "Texto",
"your_phone_number": "Su número de teléfono",
"multiline_text": "Texto de varias líneas",
"number": "Número",
"checkbox": "Casilla",
+25 -1
View File
@@ -1 +1,25 @@
{}
{
"identity_provider": "Identiteedi pakkuja",
"trial_days_left": "Teil on $t(day, {\"count\": {{days}} }) jäänud PRO prooviversiooni",
"day_one": "{{count}} päev",
"day_other": "{{count}} päeva",
"second_one": "{{count}} sekundit",
"second_other": "{{count}} sekundit",
"upgrade_now": "Uuenda nüüd",
"accept_invitation": "Võta kutse vastu",
"calcom_explained": "{{appName}} pakub ajakava infrastruktuuri absoluutselt kõigile.",
"reset_password_subject": "{{appName}}: Parooli taastamise juhend",
"verify_email_subject": "{{appName}}: Kinnita oma konto",
"check_your_email": "Kontrolli enda e-posti",
"old_email_address": "Vana e-posti aadress",
"new_email_address": "Uus e-posti aadress",
"verify_email_page_body": "Me saatsime e-kirja meiliaadressile: {{email}}. Oluline on kinnitada oma e-posti aadress, et tagada parim e-posti ja kalendri ühendus {{appName}}.",
"verify_email_banner_body": "Parima meili ja kalendri edastamise tagamiseks kinnitage oma e-posti aadress",
"verify_email_email_header": "Kinnita oma e-posti aadress",
"verify_email_email_button": "Kinnita e-posti aadress",
"verify_email_change_description": "Olete hiljuti taotlenud oma {{appName}} kontole sisselogimiseks kasutatava e-posti aadressi muutmist. Uue e-posti aadressi kinnitamiseks klõpsake alloleval nupul.",
"verify_email_change_success_toast": "Teie e-posti aadress on värskendatud: {{email}}",
"verify_email_change_failure_toast": "E-posti aadressi värskendamine ebaõnnestus.",
"change_of_email": "Kinnitage oma uus rakenduse {{appName}} e-posti aadress",
"change_of_email_toast": "Saatsime kinnituslingi aadressile {{email}}. Värskendame teie e-posti aadressi, kui klõpsate sellel lingil."
}
@@ -206,6 +206,7 @@
"2fa_confirm_current_password": "Confirmez votre mot de passe actuel pour commencer.",
"2fa_scan_image_or_use_code": "Scannez l'image ci-dessous avec votre application d'authentification ou saisissez manuellement le code.",
"text": "Texte",
"your_phone_number": "Votre numéro de téléphone",
"multiline_text": "Zone de texte",
"number": "Nombre",
"checkbox": "Case à cocher",
@@ -207,6 +207,7 @@
"2fa_confirm_current_password": "כדי להתחיל, יש לאשר את הסיסמה הנוכחית.",
"2fa_scan_image_or_use_code": "סרוק את התמונה הבאה באמצעות אפליקציית האימות (authenticator) בטלפון או הזן את קוד הטקסט ידנית.",
"text": "טקסט",
"your_phone_number": "מספר הטלפון שלך",
"multiline_text": "טקסט מרובה שורות",
"number": "מספר",
"checkbox": "תיבת סימון",
@@ -2230,6 +2231,7 @@
"troubleshooter_tooltip": "אפשר לפתוח את פותר הבעיות ולנסות לפענח מה לא תקין בלוח הזמנים שלך",
"need_help": "צריך עזרה?",
"troubleshooter": "פותר בעיות",
"number_to_call": "מספר לחיוג",
"please_install_a_calendar": "נא להתקין לוח שנה",
"instant_tab_title": "הזמנה מיידית",
"instant_event_tab_description": "לאפשר לאנשים ליצור הזמנות מיידית",
File diff suppressed because it is too large Load Diff
@@ -206,6 +206,7 @@
"2fa_confirm_current_password": "Conferma la tua password attuale per iniziare.",
"2fa_scan_image_or_use_code": "Scansiona l'immagine qui sotto con l'app di autenticazione sul tuo telefono o inserisci manualmente il codice di testo.",
"text": "Testo",
"your_phone_number": "Il tuo numero di telefono",
"multiline_text": "Testo Multilinea",
"number": "Numero",
"checkbox": "Casella di controllo",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "現在のパスワードを確認して開始します。",
"2fa_scan_image_or_use_code": "携帯電話の認証アプリで下の画像をスキャンするか、手動でテキストコードを入力してください。",
"text": "テキスト",
"your_phone_number": "あなたの電話番号",
"multiline_text": "複数行のテキスト",
"number": "番号",
"checkbox": "チェックボックス",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "시작하려면 현재 비밀번호를 확인하세요.",
"2fa_scan_image_or_use_code": "휴대폰의 인증 앱으로 아래 이미지를 스캔하거나 텍스트 코드를 수동으로 입력하세요.",
"text": "텍스트",
"your_phone_number": "본인 전화 번호",
"multiline_text": "멀티라인 텍스트",
"number": "숫자",
"checkbox": "체크박스",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Bevestig uw huidige wachtwoord om aan de slag te gaan.",
"2fa_scan_image_or_use_code": "Scan de afbeelding hieronder met de authenticator-app op uw telefoon of voer de tekstcode handmatig in.",
"text": "Tekst",
"your_phone_number": "Uw telefoonnummer",
"multiline_text": "Meerdere regels tekst",
"number": "Nummer",
"checkbox": "Selectievakje",
@@ -148,6 +148,7 @@
"2fa_confirm_current_password": "Bekreft ditt nåværende passord for å komme i gang.",
"2fa_scan_image_or_use_code": "Skann bildet nedenfor med autentiseringsappen på telefonen eller skriv inn tekstkoden manuelt i stedet.",
"text": "Tekst",
"your_phone_number": "Ditt Telefonnummer",
"multiline_text": "Flerlinjetekst",
"number": "Tall",
"checkbox": "Avmerkingsboks",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Potwierdź aktualne hasło, aby rozpocząć.",
"2fa_scan_image_or_use_code": "Zeskanuj poniższy obraz za pomocą aplikacji uwierzytelniającej na telefonie lub ręcznie wprowadź kod tekstowy.",
"text": "Tekst",
"your_phone_number": "Twój numer telefonu",
"multiline_text": "Tekst wieloliniowy",
"number": "Numer",
"checkbox": "Pole wyboru",
@@ -207,6 +207,7 @@
"2fa_confirm_current_password": "Insira a sua senha atual para começar.",
"2fa_scan_image_or_use_code": "Digitalize a imagem abaixo com o aplicativo autenticador no seu telefone, ou insira o código manualmente.",
"text": "Texto",
"your_phone_number": "Seu número de telefone",
"multiline_text": "Múltiplas linhas de texto",
"number": "Número",
"checkbox": "Caixa de seleção",
@@ -206,6 +206,7 @@
"2fa_confirm_current_password": "Confirme a sua senha atual para começar.",
"2fa_scan_image_or_use_code": "Digitalize a imagem abaixo com a aplicação de autenticação no seu telefone, ou insira o código de texto manualmente.",
"text": "Texto",
"your_phone_number": "O seu número de telefone",
"multiline_text": "Múltiplas linhas de texto",
"number": "Número",
"checkbox": "Caixa de seleção",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Confirmați parola curentă pentru a începe.",
"2fa_scan_image_or_use_code": "Scanați imaginea de mai jos cu aplicația de autentificare de pe telefon sau introduceți manual codul de text.",
"text": "Textul",
"your_phone_number": "Numărul dvs. de telefon",
"multiline_text": "Text Multiline",
"number": "Număr",
"checkbox": "Caseta de bifat",
@@ -183,6 +183,7 @@
"2fa_confirm_current_password": "Подтвердите свой текущий пароль.",
"2fa_scan_image_or_use_code": "Отсканируйте изображение ниже с помощью приложения-аутентификатора на вашем телефоне или введите текстовый код вручную.",
"text": "Текст",
"your_phone_number": "Ваш номер телефона",
"multiline_text": "Многострочный текст",
"number": "Число",
"checkbox": "Чекбокс",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Potvrdite vašu trenutnu lozinku da bi počeli.",
"2fa_scan_image_or_use_code": "Skenirajte sliku ispod telefonom sa aplikacijom za autentikaciju ili manuelno unesite tekst kod.",
"text": "Tekst",
"your_phone_number": "Vaš broj telefona",
"multiline_text": "Višelinijski Tekst",
"number": "Broj",
"checkbox": "Polje za potvrdu",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Bekräfta ditt nuvarande lösenord för att komma igång.",
"2fa_scan_image_or_use_code": "Skanna bilden nedan med autentiseringsappen på din telefon eller ange textkoden manuellt.",
"text": "Text",
"your_phone_number": "Ditt telefonnummer",
"multiline_text": "Text på flera rader",
"number": "Nummer",
"checkbox": "Kryssruta",
@@ -182,6 +182,7 @@
"2fa_confirm_current_password": "Başlamak için mevcut şifrenizi onaylayın.",
"2fa_scan_image_or_use_code": "Aşağıdaki resmi telefonunuzdaki kimlik doğrulama uygulamasını kullanarak tarayın veya bunun yerine metin kodunu manuel olarak girin.",
"text": "Metin",
"your_phone_number": "Telefon Numaranız",
"multiline_text": "Çok Satırlı Metin",
"number": "Sayı",
"checkbox": "Onay kutusu",
@@ -206,6 +206,7 @@
"2fa_confirm_current_password": "Підтвердьте свій поточний пароль, щоб почати.",
"2fa_scan_image_or_use_code": "Проскануйте зображення нижче за допомогою застосунка для автентифікації на телефоні або введіть текстовий код вручну.",
"text": "Текст",
"your_phone_number": "Ваш номер телефону",
"multiline_text": "Багаторядковий текст",
"number": "Число",
"checkbox": "Прапорець",
@@ -183,6 +183,7 @@
"2fa_confirm_current_password": "Xác nhận lại mật khẩu hiện tại của bạn để bắt đầu.",
"2fa_scan_image_or_use_code": "Quét hình ảnh dưới đây với ứng dụng \bxác thực hai yếu tố trên điện thoại bạn hoặc nhập mã code.",
"text": "Chữ",
"your_phone_number": "Số điện thoại của bạn",
"multiline_text": "Chữ nhiều dòng",
"number": "Số",
"checkbox": "Checkbox",
@@ -203,6 +203,7 @@
"2fa_confirm_current_password": "输入您当前的密码来开始设置。",
"2fa_scan_image_or_use_code": "请用您的手机上的身份验证器扫描下面的图像,或者手动输入验证码。",
"text": "文本",
"your_phone_number": "您的电话号码",
"multiline_text": "多行文本",
"number": "数字",
"checkbox": "复选框",
@@ -13,10 +13,16 @@
"reset_password_subject": "{{appName}}: 重新設置密碼說明",
"verify_email_subject": "{{appName}}:驗證您的帳號",
"check_your_email": "檢查您的電子郵件",
"old_email_address": "舊電子郵件",
"new_email_address": "新電子郵件地址",
"verify_email_page_body": "我們已傳送電子郵件至 {{email}}。請務必驗證您的電子郵件地址,以確保能準確收到 {{appName}} 遞送的電子郵件和行事曆。",
"verify_email_banner_body": "驗證您的電子郵件地址,以確保能準確收到遞送的電子郵件和行事曆",
"verify_email_email_header": "驗證您的電子郵件地址",
"verify_email_email_button": "驗證電子郵件",
"verify_email_change_description": "您最近要求更改您用於登入 {{appName}} 帳戶的電子郵件地址。請點擊下方按鈕以確認您的新電子郵件地址。",
"verify_email_change_success_toast": "將您的電子郵件更新為 {{email}}",
"verify_email_change_failure_toast": "更新電子郵件失敗。",
"change_of_email": "確認您的新 {{appName}} 電子郵件地址",
"copy_somewhere_safe": "請將此 API 金鑰記在安全的地方。您之後將無法再次查看此金鑰。",
"verify_email_email_body": "請按一下下方的按鈕來驗證電子郵件地址。",
"verify_email_by_code_email_body": "請使用下方驗證碼來驗證電子郵件地址。",
@@ -182,6 +188,7 @@
"2fa_confirm_current_password": "一開始,請確認目前的密碼。",
"2fa_scan_image_or_use_code": "使用手機的認證應用程式掃描下方的圖片,或者,手動輸入文字碼也可以。",
"text": "文字",
"your_phone_number": "您的電話號碼",
"multiline_text": "多行文字",
"number": "數字",
"checkbox": "勾選",
@@ -847,6 +854,7 @@
"next_step": "跳過步驟",
"prev_step": "上個步驟",
"install": "安裝",
"start_paid_trial": "開始免費試用",
"installed": "已安裝",
"active_install_one": "{{count}} 個有效安裝",
"active_install_other": "{{count}} 個有效安裝",
@@ -2075,6 +2083,7 @@
"access_bookings": "讀取、編輯、刪除您的預約",
"allow_client_to_do": "允許 {{clientName}} 這麼做嗎?",
"oauth_access_information": "按一下「允許」後,即表示此應用程式可根據他們的服務和隱私政策使用您的資訊。您可在 {{appName}} App Store 中移除存取權限。",
"oauth_form_description": "這是建立新的 OAuth 用戶端的表單。",
"allow": "允許",
"view_only_edit_availability_not_onboarded": "此使用者尚未完成入門導覽。在對方完成入門導覽前,您將無法設定對方的可預約時間。",
"view_only_edit_availability": "您正在查看此使用者的可預約時間。您只能編輯自己的可預約時間。",
@@ -2094,8 +2103,11 @@
"lock_timezone_toggle_on_booking_page": "鎖定預約頁面的時區",
"description_lock_timezone_toggle_on_booking_page": "鎖定預約頁面的時區,安排實體活動時非常實用。",
"extensive_whitelabeling": "專屬的入門和工程支援",
"create_your_org": "建立你的組織",
"need_help": "需要協助?",
"user_redirect_description": "在此期間,{{profile.username}} 將代表 {{username}} 負責所有新安排的會議。",
"show_more": "顯示更多",
"privacy": "隱私權",
"team_will_be_under_org": "新的團隊將會在您的組織下。",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 請在此處新增您的字串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+3 -1
View File
@@ -26,7 +26,9 @@
"../../packages/types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
"../../packages/features/bookings/lib/getBookingInfo.ts",
"../../packages/features/bookings/lib/getUserBooking.ts"
],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -109,7 +109,7 @@
},
"dependencies": {
"eslint": "^8.34.0",
"lucide-react": "^0.171.0",
"lucide-react": "^0.363.0",
"turbo": "^1.10.1"
},
"resolutions": {
@@ -6,6 +6,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { Alert, Button, Dialog, DialogClose, DialogContent, DialogFooter, Input } from "@calcom/ui";
import { Link, Search } from "@calcom/ui/components/icon";
import type { LucideIcon } from "@calcom/ui/components/icon";
interface ISearchDialog {
isOpenDialog: boolean;
@@ -85,7 +86,7 @@ export const SearchDialog = (props: ISearchDialog) => {
return null;
};
const renderTab = (Icon: SVGComponent, text: string, mode: Mode) => (
const renderTab = (Icon: SVGComponent | LucideIcon, text: string, mode: Mode) => (
<div
className={classNames(
"flex cursor-pointer items-center border-b-2 p-2 text-sm ",
@@ -24,7 +24,11 @@ export default function ICSFeedSetup() {
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img src="/api/app-store/ics-feed/icon.svg" alt="ICS Feed" className="h-12 w-12 max-w-2xl" />
<img
src="/api/app-store/ics-feedcalendar/icon.svg"
alt="ICS Feed"
className="h-12 w-12 max-w-2xl"
/>
</div>
<div className="flex w-10/12 flex-col">
<h1 className="text-default">{t("connect_ics_feed")}</h1>
@@ -34,7 +38,7 @@ export default function ICSFeedSetup() {
form={form}
handleSubmit={async (_) => {
setErrorMessage("");
const res = await fetch("/api/integrations/ics-feed/add", {
const res = await fetch("/api/integrations/ics-feedcalendar/add", {
method: "POST",
body: JSON.stringify({ urls }),
headers: {
+5
View File
@@ -26,6 +26,11 @@ vi.mock("@calcom/atoms/monorepo", () => ({
return false;
},
}));
vi.mock("@calcom/lib", () => ({
classNames: (...args: string[]) => {
return args.filter(Boolean).join(" ");
},
}));
vi.mock("@calcom/lib/event-types/getEventTypesByViewer", () => ({}));
vi.mock("@calcom/lib/event-types/getEventTypesPublic", () => ({}));

Some files were not shown because too many files have changed in this diff Show More