Compare commits

...
34 Commits
Author SHA1 Message Date
GitHub Actions 71a5d8b261 chore: release v5.3.7
Post release / release (push) Canceled after 0s
2025-05-27 22:35:54 +00:00
Alex van Andel dfc022d890 Revert "perf: parallelize getBusyTimes calls to improve performance (#21372)"
This reverts commit a06b2009ab.
2025-05-27 23:30:30 +01:00
GitHub Actions 68cb0cf197 chore: release v5.3.7 2025-05-27 22:28:37 +00:00
Syed Ali ShahbazandUdit Takkar 14592de6c4 fix: Anonymize host email in ics (#21544)
* add virta check for anonymizing host

* move to env var

* move env var to constants

* fix: filter empty string

* fix test

* fix test

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-27 19:28:03 +01:00
Omar López d41dcc69ad refactor: prevent mixed imports (#21500)
Signed-off-by: Omar López <zomars@me.com>
2025-05-27 11:55:02 -04:00
Eunjae Lee 0a28b5825b fix: date range computation on /insights (#21436)
* fix: date range computation on /insights

* remove log

* error handling

* update test cases

* WIP

* fix implementation

* support weekStart

* clean up

* fix date range presets

* clean up timeView

* fix timeView

* clean up

* fix event trends

* remove unused variables

* fix flaky e2e tests

* fix date range

* update formatting logic
2025-05-27 17:47:40 +02:00
Eunjae Lee 3131318b6a fix: hide organizer info on rescheduled events (#21541)
* fix(email): hide organizer info on rescheduled events

* hide organizer email on bookings-single-view

* replace organizer email with name

* sanitize previous booking only

* clean up code

* fix dumb error
2025-05-27 15:35:16 +00:00
sean-brydonandAlex van Andel 08e35e4698 perf: stop merging routers twice. (#21538)
* move away from merging twice

* move to use loggedIn Router directly

* fix router outputs types

* fix mocks in create button with teasm tests

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-27 15:42:14 +01:00
ed4b3f082a feat: cal video settings (#21347)
* feat: cal video settings

* fix: var names

* feat: finish setting

* chore: add enabled

* chore: remove logs

* feat: support redirect url on exit

* feat: finish settings

* refactor: improve UI

* chore: remove logs

* fix: type err

* fix: finish redirect url

* fix: add type

* chore: change schema

* fix: type error

* chore: improvements

* chore: remove index

* feat: add organizer

* chore: remove log

* chore: use safe parse

* fix: add nullable

* refactor: hide toggles if platorm

* chore: update description

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-05-27 10:13:24 -04:00
Rajiv SahalandAnik Dhabal Babu a1007398f6 fix: update stripe webhooks to handle past due subscriptions (#20466)
* handle case for past due subscriptions

* update user subscription again if active

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-27 13:06:42 +00:00
Lauris Skraucis 2aafa1c163 feat: v2 managed organizations pagination (#21359)
* feat: getPagination helper

* refactor: bookings use getPagination helper

* feat: getPagination helper

* refactor: group pagination inputs and outputs in types/pagination

* feat: paginated managed orgs

* fix tests

* swagger
2025-05-27 13:50:31 +03:00
FaizandUdit Takkar 008178480a fix: 'Hide from profile' toggle background not sized correctly on mobile (#21535)
* Fix: 'Hide from profile' toggle background not sized correctly on mobile

* fix: changed padding back to original

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-26 18:15:29 -04:00
Amit SharmaandPeer Richelsen 2334c3bef8 fix: optimize SEO healthscore (#21509)
Post release / release (push) Canceled after 0s
* compress app store images

* replace http linkages with https

* fix link raycast

* update http links to https

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-26 20:24:08 +00:00
GitHub Actions 9542116ec1 chore: release v5.3.6 2025-05-26 19:58:52 +00:00
Anik Dhabal Babu f9c11c2856 fix: (Reschedule) Email Settings being ignored for RR event (#21520)
* fix: delete dilog button not visible

* fix: (Reschedule) Email Settings being ignored for RR event

* update
2025-05-26 16:17:05 +00:00
Hariom BalharaandOmar López 30060f03eb fix: GoogleCalendar - Ensure that Delegation Credentials are able to reuse access_token (#21389)
Co-authored-by: Omar López <zomars@me.com>
2025-05-26 15:19:52 +01:00
Vijay b8ffcef45e fix: Remove global GTM container on private booking page. (#21490)
* add route filter to inject script

* update middleware for pathname as Referer might not work all time

* move d to (booking-page-wrapper) instead of route filter and change in middleware

* add e2e tests
2025-05-26 10:14:24 +05:30
Rajiv Sahal 999882b795 dont pass attribues and facetedTeamValues for platform (#21508) 2025-05-25 22:17:42 +00:00
Omar López bda963b851 refactor: prevents mixed server/clients imports (#21499) 2025-05-25 09:07:49 +00:00
GitHub Actions 8511494d68 chore: release v5.3.5
Post release / release (push) Canceled after 0s
2025-05-25 00:08:53 +00:00
Benny Joo 1c3ad9dad6 refactor: make routing forms code more maintainable (#21478)
* create layout for routing/forms

* refactor

* refators

* fix

* clean up
2025-05-24 22:44:05 +00:00
Anik Dhabal Babu d43afe7dbd fix: e2e test failure in ci (#21510)
* fix: delete dilog button not visible

* fix: global e2e fialure

* update

* tweak
2025-05-24 13:11:36 -04:00
devin-ai-integration[bot]andkeith@cal.com a06b2009ab perf: parallelize getBusyTimes calls to improve performance (#21372)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-24 08:43:06 -04:00
Omar López 2a786b4652 Revert "test: fix few e2e flakes (#21408)" (#21501) 2025-05-23 15:30:33 -07:00
Anik Dhabal Babu ef1844557f fix: Host schedule not updating upon updating/deleting default schedule (#20750)
* fix: host schdule not updating

* Update util.ts

* tweak

* Update delete.handler.ts

* add test

* update

* Update util.test.ts
2025-05-23 17:53:40 -04:00
Alex van Andel d1f7b043e8 chore: Improve error handling for avatar API, shouldn't result in timeouts (#21493) 2025-05-23 18:08:04 +00:00
3a8e40d029 feat(calendar): add error tracking with attempts to SelectedCalendar (#21326)
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-23 16:38:54 +00:00
bca46228e7 feat: upgrade typescript to 5.8.3 (#21449)
* feat: upgrade typescript to 5.8.3

Co-Authored-By: joe@cal.com <joe@cal.com>

* chore: fix TypeScript 5.8.3 compatibility issues

Co-Authored-By: joe@cal.com <joe@cal.com>

* Uncomment `optmizeImage`

* Type fix

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Joe <j.auyeung419@gmail.com>
2025-05-23 13:57:37 +01:00
Omar López d15b24b8ea ci: remove deprecated submodule code (#20731) 2025-05-23 12:25:33 +00:00
Udit TakkarandSyed Ali Shahbaz d9df9b3d01 feat: recording url with 6 months expiry time (#18707)
* feat: store cal video recording on s3

* feat: create new route to fetch download link

* tests: add unit test

* fix: unit test

* fix: type error

* chore: add env var

* fix: open in new tab

* fix: type err

* chore: update test

* fix: import

* chore: use cal video recording token secret

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-05-23 13:01:33 +01:00
Lauris Skraucis fc1d6ae402 refactor: platform docs and oauth webhook auth (#21488)
* fix: Cannot read properties of undefined (reading 'id')

* docs: managed users dont have cal.com page

* docs: reassign works only for round robin bookings

* refactor: put OAuthClientWebhooksController behind ApiAuthGuard

* fix: tests

* fix: typo
2025-05-23 14:06:47 +03:00
Peer Richelsen 26b4b7a868 fixed two dark mode app logos (#21482) 2025-05-23 08:08:27 +00:00
Anik Dhabal Babu a839630ca3 test: fix few e2e flakes (#21408)
* fix flakes

* update

* Update booking.e2e.ts

* update

* Update booking.e2e.ts

* Update booking.e2e.ts
2025-05-23 12:40:27 +05:30
Hariom Balhara e21dbb704e fix error in selected-calendars cron for delegation credential (#21465) 2025-05-23 03:37:24 +00:00
220 changed files with 5112 additions and 2158 deletions
+4
View File
@@ -445,6 +445,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
# Used to enable quick availability checks for x% of all visitors
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
# Used to generate token for cal video recordings expiry token
# You can use: `openssl rand -base64 32` to generate one
CAL_VIDEO_RECORDING_TOKEN_SECRET=
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
-21
View File
@@ -1,21 +0,0 @@
name: Submodule Sync
on:
schedule:
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
- cron: "15 */4 * * *"
workflow_dispatch: ~
jobs:
submodule-sync:
name: Submodule update
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Commit
run: |
git config user.email "actions@github.com"
git config user.name "github-actions"
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "Updating submodules recursively"
pwd
git submodule update --init --recursive
View File
View File
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
/** No watch related fields support in API v1 */
watchAttempts: true,
unwatchAttempts: true,
maxAttempts: true,
});
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
@@ -38,7 +38,8 @@ describe("GET /api/bookings", async () => {
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
});
it("Returns bookings for regular user", async () => {
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
+1 -1
View File
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.8.2"
"typescript": "^5.8.3"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -290,7 +290,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to auto-selected host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -311,7 +312,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to a specific host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -5,6 +5,7 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { getPagination } from "@/lib/pagination/pagination";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
@@ -108,6 +109,12 @@ export class BookingsService_2024_08_13 {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
if (eventType.schedulingType === "MANAGED") {
throw new BadRequestException(
`Event type with id=${eventType.id} is the parent managed event type that can't be booked. You have to provide the child event type id aka id of event type that has been assigned to one of the users.`
);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
@@ -532,10 +539,13 @@ export class BookingsService_2024_08_13 {
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
}
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
take: queryParams.take ?? 100,
skip,
take,
filters: {
...this.inputService.transformGetBookingsFilters(queryParams),
...(userIds?.length ? { userIds } : {}),
@@ -588,28 +598,11 @@ export class BookingsService_2024_08_13 {
}
}
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const itemsPerPage = take;
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
const currentPage = Math.floor(skip / itemsPerPage) + 1;
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
const hasPreviousPage = skip > 0;
const pagination = getPagination({ skip, take, totalCount: fetchedBookings.totalCount });
return {
bookings: formattedBookings,
pagination: {
totalItems: fetchedBookings.totalCount,
// clamp remainingItems between 0 and totalCount
remainingItems: Math.min(
Math.max(fetchedBookings.totalCount - (skip + take), 0),
fetchedBookings.totalCount
),
itemsPerPage: itemsPerPage,
currentPage: currentPage,
totalPages: totalPages,
hasNextPage: hasNextPage,
hasPreviousPage: hasPreviousPage,
},
pagination,
};
}
@@ -0,0 +1,101 @@
import { getPagination, clamp } from "./pagination";
describe("getPagination", () => {
it("handles the first page correctly", () => {
const pagination = getPagination({ skip: 0, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 10,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 25,
currentPage: 1,
totalPages: 4,
hasNextPage: true,
hasPreviousPage: false,
});
});
it("handles a middle page correctly", () => {
const pagination = getPagination({ skip: 10, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 10,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 15,
currentPage: 2,
totalPages: 4,
hasNextPage: true,
hasPreviousPage: true,
});
});
it("handles the last page when it is not completely full", () => {
const pagination = getPagination({ skip: 30, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 5,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 4,
totalPages: 4,
hasNextPage: false,
hasPreviousPage: true,
});
});
it("clamps skip values that exceed the total item count", () => {
const pagination = getPagination({ skip: 40, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 4,
totalPages: 4,
hasNextPage: false,
hasPreviousPage: true,
});
});
it("works when itemsPerPage (take) is zero", () => {
const pagination = getPagination({ skip: 0, take: 0, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 35,
itemsPerPage: 0,
remainingItems: 35,
currentPage: 0,
totalPages: 0,
hasNextPage: true,
hasPreviousPage: false,
});
});
it("works when the collection is empty", () => {
const pagination = getPagination({ skip: 0, take: 10, totalCount: 0 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 0,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
});
});
});
describe("clamp", () => {
it("returns the value unchanged when it is inside the range", () => {
expect(clamp({ value: 5, min: 1, max: 10 })).toBe(5);
});
it("clamps values below the minimum", () => {
expect(clamp({ value: -3, min: 0, max: 10 })).toBe(0);
});
it("clamps values above the maximum", () => {
expect(clamp({ value: 42, min: 0, max: 10 })).toBe(10);
});
});
@@ -0,0 +1,78 @@
import { PaginationMetaDto } from "@calcom/platform-types";
type Pagination = {
skip: number;
take: number;
totalCount: number;
};
export function getPagination(pagination: Pagination): PaginationMetaDto {
const { skip, take, totalCount } = pagination;
const safeSkip = clamp({ value: skip, min: 0, max: totalCount });
const itemsPerPage = take;
const remainingItems = getRemainingItems(safeSkip, itemsPerPage, totalCount);
const totalPages = getTotalPages(itemsPerPage, totalCount);
const currentPage = getCurrentPage(safeSkip, itemsPerPage, totalPages);
const hasNextPage = getHasNextPage(safeSkip, itemsPerPage, totalCount);
const hasPreviousPage = getHasPreviousPage(safeSkip);
const returnedItems = getReturnedItems(safeSkip, itemsPerPage, totalCount);
return {
returnedItems,
totalItems: totalCount,
itemsPerPage,
remainingItems,
currentPage,
totalPages,
hasNextPage,
hasPreviousPage,
};
}
function getRemainingItems(skip: number, itemsPerPage: number, totalCount: number) {
return clamp({
value: totalCount - (skip + itemsPerPage),
min: 0,
max: totalCount,
});
}
function getTotalPages(itemsPerPage: number, totalCount: number) {
return itemsPerPage !== 0 ? Math.ceil(totalCount / itemsPerPage) : 0;
}
function getCurrentPage(skip: number, itemsPerPage: number, totalPages: number) {
const rawCurrentPage = Math.floor(skip / itemsPerPage) + 1;
if (totalPages === 0) {
return 0;
}
return clamp({ value: rawCurrentPage, min: 1, max: totalPages });
}
function getHasNextPage(skip: number, itemsPerPage: number, totalCount: number) {
return skip + itemsPerPage < totalCount;
}
function getHasPreviousPage(skip: number) {
return skip > 0;
}
function getReturnedItems(skip: number, itemsPerPage: number, totalCount: number) {
const remaining = totalCount - skip;
return clamp({
value: remaining,
min: 0,
max: itemsPerPage,
});
}
type ClampArgs = {
value: number;
min: number;
max: number;
};
export function clamp({ value, min, max }: ClampArgs): number {
return Math.max(min, Math.min(value, max));
}
@@ -124,6 +124,9 @@ export class BillingController {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.updated":
await this.billingService.handleStripePaymentPastDue(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
@@ -216,6 +216,30 @@ export class BillingService implements OnModuleDestroy {
}
}
async handleStripePaymentPastDue(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
const existingUserSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(subscriptionId);
if (existingUserSubscription.status === "past_due") {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
}
if (existingUserSubscription.status === "active") {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
}
}
if (!subscriptionId || !customerId) {
this.logger.log(`SubscriptionId: ${subscriptionId} or customerId: ${customerId} missing`);
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
@@ -21,7 +21,7 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
import { withApiAuth } from "test/utils/withApiAuth";
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
@@ -47,7 +47,7 @@ describe("OAuth client WebhooksController (e2e)", () => {
let webhook: OAuthClientWebhookOutputResponseDto["data"];
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -1,6 +1,6 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsOAuthClientWebhookGuard } from "@/modules/webhooks/guards/is-oauth-client-webhook-guard";
@@ -29,7 +29,7 @@ import { OAuthClientGuard } from "../../guards/oauth-client-guard";
path: "/v2/oauth-clients/:clientId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@DocsTags("Platform / Webhooks")
@ApiHeader({
name: X_CAL_SECRET_KEY,
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { SkipTakePagination } from "@calcom/platform-types";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -34,11 +35,26 @@ export class ManagedOrganizationsRepository {
});
}
async getByManagerOrganizationId(managerOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findMany({
where: {
managerOrganizationId,
},
});
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
const { skip, take } = pagination;
const where: Prisma.ManagedOrganizationWhereInput = {
managerOrganizationId,
};
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
this.dbRead.prisma.managedOrganization.count({ where }),
this.dbRead.prisma.managedOrganization.findMany({
where,
skip,
take,
orderBy: { managedOrganizationId: "asc" },
include: { managedOrganization: true },
}),
]);
const items = linkRows.map((l) => l.managedOrganization);
return { totalItems, items };
}
}
@@ -7,6 +7,7 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { GetManagedOrganizationsOutput } from "@/modules/organizations/organizations/outputs/get-managed-organizations.output";
import {
ManagedOrganizationWithApiKeyOutput,
ManagedOrganizationOutput,
@@ -304,7 +305,7 @@ describe("Organizations Organizations Endpoints", () => {
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput[]> = response.body;
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
@@ -312,6 +313,14 @@ describe("Organizations Organizations Endpoints", () => {
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(1);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(1);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
@@ -28,10 +28,12 @@ import {
HttpCode,
HttpStatus,
Delete,
Query,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
const SCALE = "SCALE";
@@ -99,14 +101,15 @@ export class OrganizationsOrganizationsController {
"Requires the user to have at least the 'ORG_ADMIN' role within the organization. Additionally, for platform, the plan must be 'SCALE' or higher to access this endpoint.",
})
async getOrganizations(
@Param("orgId", ParseIntPipe) managerOrganizationId: number
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
@Query() queryPagination: SkipTakePagination
): Promise<GetManagedOrganizationsOutput> {
const organizations = await this.managedOrganizationsService.getManagedOrganizations(
managerOrganizationId
);
const { organizations, pagination: responsePagination } =
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
return {
status: SUCCESS_STATUS,
data: organizations,
pagination: responsePagination,
};
}
@@ -4,6 +4,7 @@ import { Expose, Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { PaginationMetaDto } from "@calcom/platform-types";
export class GetManagedOrganizationsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@@ -17,4 +18,9 @@ export class GetManagedOrganizationsOutput {
@ValidateNested({ each: true })
@Type(() => ManagedOrganizationOutput)
data!: ManagedOrganizationOutput[];
@ApiProperty({ type: () => PaginationMetaDto })
@Type(() => PaginationMetaDto)
@ValidateNested()
pagination!: PaginationMetaDto;
}
@@ -1,3 +1,4 @@
import { getPagination } from "@/lib/pagination/pagination";
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
@@ -11,6 +12,7 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Injectable()
export class ManagedOrganizationsService {
@@ -99,18 +101,22 @@ export class ManagedOrganizationsService {
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
}
async getManagedOrganizations(managerOrganizationId: number) {
const managedOrganizations = await this.managedOrganizationsRepository.getByManagerOrganizationId(
managerOrganizationId
);
const managedOrganizationsIds = managedOrganizations.map(
(managedOrganization) => managedOrganization.managedOrganizationId
);
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
const { items: managedOrganizations, totalItems } =
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
managerOrganizationId,
pagination
);
const organizations = await this.organizationsRepository.findByIds(managedOrganizationsIds);
return organizations.map((organization) =>
this.managedOrganizationsOutputService.getOutputManagedOrganization(organization)
);
return {
organizations: managedOrganizations.map((managedOrganization) =>
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
),
pagination: getPagination({
...pagination,
totalCount: totalItems,
}),
};
}
async updateManagedOrganization(managedOrganizationId: number, body: UpdateOrganizationInput) {
+283 -117
View File
@@ -446,9 +446,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -456,9 +459,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -654,9 +659,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -664,9 +672,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -1526,6 +1536,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -1928,9 +1948,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -1938,9 +1961,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2629,9 +2654,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2639,9 +2667,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2707,9 +2737,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2717,9 +2750,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2851,9 +2886,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2861,9 +2899,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4185,9 +4225,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4195,9 +4238,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4271,9 +4316,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4281,9 +4329,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -5442,6 +5492,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -5675,9 +5735,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5685,9 +5748,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -5951,9 +6016,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5961,9 +6029,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -6388,9 +6458,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -6398,9 +6471,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -6819,6 +6894,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -7251,7 +7336,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Reassign a booking to auto-selected host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -7302,7 +7387,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -8741,9 +8826,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -8751,9 +8839,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -9012,6 +9102,31 @@
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
],
"responses": {
@@ -9670,9 +9785,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9680,9 +9798,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -9730,9 +9850,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9740,9 +9863,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -11573,9 +11698,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11583,9 +11711,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -11962,9 +12092,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11972,9 +12105,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12022,9 +12157,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12032,9 +12170,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12342,9 +12482,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12352,9 +12495,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12394,9 +12539,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12404,9 +12552,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12576,9 +12726,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12586,9 +12739,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -15029,6 +15184,7 @@
},
"address": {
"type": "string",
"minLength": 1,
"example": "123 Example St, City, Country"
},
"public": {
@@ -21967,6 +22123,66 @@
"data"
]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123,
"minimum": 0
},
"remainingItems": {
"type": "number",
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103,
"minimum": 0
},
"returnedItems": {
"type": "number",
"description": "The number of items returned in the current page.",
"example": 10
},
"itemsPerPage": {
"type": "number",
"description": "The maximum number of items requested per page.",
"example": 10,
"minimum": 1
},
"currentPage": {
"type": "number",
"description": "The current page number being returned.",
"example": 2,
"minimum": 1
},
"totalPages": {
"type": "number",
"description": "The total number of pages available.",
"example": 13,
"minimum": 0
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"returnedItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetManagedOrganizationsOutput": {
"type": "object",
"properties": {
@@ -21983,11 +22199,15 @@
"items": {
"$ref": "#/components/schemas/ManagedOrganizationOutput"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationMetaDto"
}
},
"required": [
"status",
"data"
"data",
"pagination"
]
},
"UpdateOrganizationInput": {
@@ -24174,60 +24394,6 @@
"data"
]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"minimum": 0,
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123
},
"remainingItems": {
"type": "number",
"minimum": 0,
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103
},
"itemsPerPage": {
"type": "number",
"minimum": 1,
"description": "The maximum number of items requested per page.",
"example": 10
},
"currentPage": {
"type": "number",
"minimum": 1,
"description": "The current page number being returned.",
"example": 2
},
"totalPages": {
"type": "number",
"minimum": 0,
"description": "The total number of pages available.",
"example": 13
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetBookingsOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import FormProvider from "./FormProvider";
export default async function Layout({ children }: { children: ReactNode }) {
return <FormProvider>{children}</FormProvider>;
}
@@ -5,29 +5,23 @@ import { cookies, headers } from "next/headers";
import { routingFormsComponents } from "@calcom/app-store/routing-forms/pages/app-routing.client-config";
import type { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
import Shell from "@calcom/features/shell/Shell";
import { getServerSideProps } from "@lib/apps/routing-forms/[...pages]/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import FormProvider from "./FormProvider";
const normalizePages = (pages: string[] | string | undefined) => {
const normalizedPages = Array.isArray(pages) ? pages : pages?.split("/") ?? [];
return {
mainPage: normalizedPages[0] ?? "forms",
subPages: normalizedPages.slice(1),
};
return normalizedPages[0];
};
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const { mainPage } = normalizePages((await params).pages);
const mainPage = normalizePages((await params).pages);
return await _generateMetadata(
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
() => "",
undefined,
undefined,
`/apps/routing-forms/${(await params).pages.join("/")}`
`/routing/${(await params).pages.join("/")}`
);
};
@@ -38,25 +32,11 @@ const getData = withAppDirSsr<GetServerSidePropsResult>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const { mainPage } = normalizePages((await params).pages);
const mainPage = normalizePages((await params).pages);
const Component = await routingFormsComponents[mainPage as keyof typeof routingFormsComponents]();
const FinalComponent = () => (
<FormProvider>
<Component {...(props as any)} />
</FormProvider>
);
// TODO: we should really be using layout for this.
if (mainPage === "forms") {
return (
<Shell withoutMain>
<FinalComponent />
</Shell>
);
}
return <FinalComponent />;
return <Component {...(props as any)} />;
};
export default ServerPage;
@@ -0,0 +1,21 @@
import { _generateMetadata } from "app/_utils";
import Forms from "@calcom/app-store/routing-forms/pages/forms/[...appPages]";
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const pages = (await params).pages;
return await _generateMetadata(
(t) => `${t("routing_forms")} | Cal.com Forms`,
() => "",
undefined,
undefined,
`/routing/forms/${pages?.length > 0 ? pages.join("/") : ""}`
);
};
const ServerPage = async () => {
return <Forms appUrl="/routing" />;
};
export default ServerPage;
@@ -0,0 +1,23 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import Shell from "@calcom/features/shell/Shell";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import FormProvider from "../[...pages]/FormProvider";
export default async function Layout({ children }: { children: ReactNode }) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
return (
<Shell withoutMain={true}>
<FormProvider>{children}</FormProvider>
</Shell>
);
}
@@ -2,7 +2,6 @@ import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
export const generateMetadata = async () =>
@@ -15,25 +14,10 @@ export const generateMetadata = async () =>
);
const ServerPageWrapper = async () => {
const [orgCaller, attributesCaller] = await Promise.all([
createRouterCaller(viewerOrganizationsRouter),
createRouterCaller(attributesRouter),
]);
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
orgCaller.listCurrent(),
orgCaller.getTeams(),
orgCaller.getFacetedValues(),
attributesCaller.list(),
]);
const [orgCaller] = await Promise.all([createRouterCaller(viewerOrganizationsRouter)]);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
return (
<PlatformMembersView
org={org}
teams={teams}
facetedTeamValues={facetedTeamValues}
attributes={attributes}
/>
);
return <PlatformMembersView org={org} teams={teams} />;
};
export default ServerPageWrapper;
@@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
import { handleCreateSelectedCalendars } from "../route";
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
// Mock GoogleCalendarService
const fetchPrimaryCalendarMock = vi.fn();
@@ -148,23 +148,50 @@ describe("handleCreateSelectedCalendars integration", () => {
]);
});
it("creates a Selected Calendar even if the user's primary calendar ID does not match their email address", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
describe("when the user's primary calendar ID does not match their email address", () => {
it("creates a Selected Calendar with the primaryCalendarId as the externalId if user's email is not a plus based variant of primaryCalendarId", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
await expectSelectedCalendars([
{ userId: user.id, externalId: "notuser@example.com", delegationCredentialId: delegationCredential.id },
]);
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{
userId: user.id,
externalId: "notuser@example.com",
delegationCredentialId: delegationCredential.id,
},
]);
});
it("creates a Selected Calendar with the user's email as the externalId if user's email is a plus based variant of primaryCalendarId", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1+test@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{ userId: user.id, externalId: user.email, delegationCredentialId: delegationCredential.id },
]);
});
});
it("does not create duplicate Selected Calendars for users who already have a valid one", async () => {
@@ -274,3 +301,35 @@ describe("handleCreateSelectedCalendars integration", () => {
});
});
});
describe("isSameEmail", () => {
it("returns true if variant email is a plus based variant of main email", () => {
expect(
isSameEmail({
mainEmail: "user1@example.com",
variantEmail: "user1+test@example.com",
emailProvider: "google",
})
).toBe(true);
});
it("returns false if variant email is different from main email", () => {
expect(
isSameEmail({
mainEmail: "user1@example.com",
variantEmail: "user1@domain.com",
emailProvider: "google",
})
).toBe(false);
});
it("returns true if variant email is same as main email but with different casing", () => {
expect(
isSameEmail({
mainEmail: "User1@example.com",
variantEmail: "user1+test@Example.com",
emailProvider: "google",
})
).toBe(true);
});
});
@@ -29,6 +29,25 @@ const validateRequest = (req: NextRequest) => {
}
};
export function isSameEmail({
mainEmail,
variantEmail,
emailProvider,
}: {
mainEmail: string;
variantEmail: string;
emailProvider: "google";
}) {
if (emailProvider !== "google") {
return mainEmail.toLowerCase() === variantEmail.toLowerCase();
}
const variantEmailParts = variantEmail.split("@");
const variantEmailUserName = variantEmailParts[0];
const variantEmailDomain = variantEmailParts[1];
const variantEmailUserNameWithoutPlus = variantEmailUserName.split("+")[0];
return mainEmail.toLowerCase() === `${variantEmailUserNameWithoutPlus}@${variantEmailDomain}`.toLowerCase();
}
type DelegationUserCredential = Awaited<
ReturnType<typeof CredentialRepository.findAllDelegationByTypeIncludeUserAndTake>
>[number];
@@ -156,6 +175,9 @@ async function fetchPrimaryCalendarId({
return primaryCalendarId;
}
/**
* Creates a SelectedCalendar record corresponding to Delegation User Credential - that would normally use primary calendar Id as the externalId but in some cases, it would use user's email as the externalId
*/
async function processDelegationUserCredential(
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
) {
@@ -166,30 +188,47 @@ async function processDelegationUserCredential(
return;
}
const externalCalendarId = await fetchPrimaryCalendarId({
const primaryCalendarId = await fetchPrimaryCalendarId({
googleCalendarService,
delegationUserCredential,
});
if (!externalCalendarId) {
if (!primaryCalendarId) {
log.error(
`External calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`
);
throw new Error("externalCalendarId could not be determined");
}
if (externalCalendarId !== userEmail) {
// We don't know the scenario when Delegation Credential allows access to an email that is not the primary calendar email
// So log a warning for further investigation
log.warn(
`External calendar id mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify({
externalCalendarId,
userEmail,
})
);
let externalCalendarId = primaryCalendarId;
if (primaryCalendarId !== userEmail) {
const areEmailsSame = isSameEmail({
emailProvider: "google",
mainEmail: primaryCalendarId,
variantEmail: userEmail,
});
if (areEmailsSame) {
log.info(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}. Using userEmail as the externalCalendarId as it is a plus based variant of primaryCalendarId`
);
// We have found that for some plus based email addresses(like john+test@acme.com) fetching primary calendar is successful and the primaryCalendarId comes back as john@acme.com
// Using userEmail as the externalCalendarId would ensure that the SelectedCalendar isn't attempted to be re-created and the SelectedCalendar record is still logically correct
externalCalendarId = userEmail;
} else {
// Unknown reason for mismatch, so we log it.
log.warn(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify({
externalCalendarId,
userEmail,
})
);
}
}
// This creation call isn't expected to fail as we have already checked for the existence of the SelectedCalendar by userEmail
await SelectedCalendarRepository.create({
integration: "google_calendar",
externalId: externalCalendarId,
+2 -1
View File
@@ -192,11 +192,12 @@ async function getHandler(request: NextRequest) {
try {
const response = await fetch(filteredLogo);
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
let buffer: Buffer = Buffer.from(arrayBuffer);
// If we need to resize the team logos (via Next.js' built-in image processing)
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
buffer = await optimizeImage({
buffer,
contentType: detectContentType(buffer) ?? "image/jpeg",
@@ -9,15 +9,16 @@ import {
sendDailyVideoRecordingEmails,
sendDailyVideoTranscriptEmails,
} from "@calcom/emails/daily-video-emails";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import {
getAllTranscriptsAccessLinkFromMeetingId,
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/lib/videoClient";
import { generateVideoToken } from "@calcom/lib/videoTokens";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
@@ -26,7 +27,6 @@ import {
meetingEndedSchema,
recordingReadySchema,
batchProcessorJobFinishedSchema,
downloadLinkSchema,
testRequestSchema,
} from "@calcom/web/lib/daily-webhook/schema";
import {
@@ -44,10 +44,9 @@ const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHead
return computed_signature;
};
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const getProxyDownloadLinkOfCalVideo = async (recordingId: string) => {
const token = generateVideoToken(recordingId);
const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`;
return downloadLink;
};
@@ -110,7 +109,7 @@ export async function postHandler(request: NextRequest) {
},
});
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
const downloadLink = await getProxyDownloadLinkOfCalVideo(recording_id);
const teamId = await getTeamIdFromEventType({
eventType: {
@@ -192,7 +191,7 @@ export async function postHandler(request: NextRequest) {
const evt = await getCalendarEvent(booking);
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
const recording = await getProxyDownloadLinkOfCalVideo(input.recordingId);
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
await triggerTranscriptionGeneratedWebhook({
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { describe, expect, test, vi, afterEach } from "vitest";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
import { GET } from "../route";
vi.mock("@calcom/lib/videoClient", () => ({
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
}));
vi.mock("@calcom/lib/videoTokens", () => ({
verifyVideoToken: vi.fn(),
}));
describe("GET /api/video/recording", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("returns 401 when token is missing", async () => {
const request = new Request("http://example.com/api/video/recording");
const response = await GET(request);
expect(response.status).toBe(401);
expect(await response.text()).toBe("Missing token");
});
test("returns 401 when token is invalid", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: false });
const request = new Request("http://example.com/api/video/recording?token=invalid_token");
const response = await GET(request);
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid or expired token");
});
test("returns 404 when recording is not found", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue(undefined);
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Recording not found");
});
test("redirects to download link when recording exists", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link: "https://example.com/download",
});
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response instanceof NextResponse).toBe(true);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://example.com/download");
});
test("returns 500 when getting recording fails", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockRejectedValue(new Error("Failed to get recording"));
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Failed to get recording");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");
if (!token) {
return new Response("Missing token", { status: 401 });
}
const verification = verifyVideoToken(token);
if (!verification.valid || !verification.recordingId) {
return new Response("Invalid or expired token", { status: 401 });
}
try {
const recordingLink = await getDownloadLinkOfCalVideoByRecordingId(verification.recordingId);
if (!recordingLink) {
return new Response("Recording not found", { status: 404 });
}
return NextResponse.redirect(recordingLink.download_link, { status: 302 });
} catch (error) {
console.error("Failed to get recording:", error);
return new Response("Failed to get recording", { status: 500 });
}
}
@@ -131,7 +131,7 @@ function BookingListItem(booking: BookingItemProps) {
};
});
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
// Invalidate and refetch the bookings query to update the UI
@@ -938,7 +938,7 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
utils.viewer.bookings.invalidate();
@@ -1030,7 +1030,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
};
});
const { t } = useLocale();
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(t(data.message), "success");
},
@@ -1133,7 +1133,7 @@ const NoShowAttendeesDialog = ({
}))
);
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
const newValue = data.attendees[0];
setNoShowAttendees((old) =>
@@ -44,7 +44,7 @@ const obtainNewUsernameChangeCondition = ({
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
stripeCustomer: RouterOutputs["viewer"]["loggedInViewerRouter"]["stripeCustomer"] | undefined;
}) => {
if (!userIsPremium && isNewUsernamePremium) {
return UsernameChangeStatusEnum.UPGRADE;
@@ -72,7 +72,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
const [markAsError, setMarkAsError] = useState(false);
const recentAttemptPaymentStatus = searchParams?.get("recentAttemptPaymentStatus");
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
const { data: stripeCustomer } = trpc.viewer.loggedInViewerRouter.stripeCustomer.useQuery();
const isCurrentUsernamePremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
@@ -94,6 +94,7 @@ const getEventTypes = async (userId: number, teamIds?: number[]) => {
userId: true,
destinationCalendar: true,
bookingFields: true,
calVideoSettings: true,
});
let eventTypeGroups: TEventTypeGroup[] | null = [];
@@ -1,7 +1,6 @@
import type { GetServerSidePropsResult, GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
@@ -33,13 +32,6 @@ export async function getServerSideProps(
const session = await getServerSession({ req });
const user = session?.user;
const app = await getAppWithMetadata({ slug: "routing-forms" });
if (!app) {
return {
notFound: true,
};
}
const result = await getServerSideProps(context as AppGetServerSidePropsContext, prisma, user);
@@ -53,7 +45,7 @@ export async function getServerSideProps(
return {
props: {
appUrl: app.simplePath || `/apps/routing-forms`,
appUrl: "/routing",
...result.props,
},
};
@@ -15,7 +15,7 @@ import { describe, afterEach, test, vi, beforeEach, beforeAll, expect } from "vi
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -70,9 +70,9 @@ vi.mock("@calcom/app-store/dailyvideo/lib", () => {
};
});
vi.mock("@calcom/lib/videoClient", () => {
vi.mock("@calcom/lib/videoTokens", () => {
return {
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
generateVideoToken: vi.fn().mockReturnValue("MOCK_TOKEN"),
};
});
@@ -189,7 +189,6 @@ describe("Handler: /api/recorded-daily-video", () => {
const bookingUid = "n5Wv3eHgconAED2j4gcVhP";
const iCalUID = `${bookingUid}@Cal.com`;
const subscriberUrl = "http://my-webhook.example.com";
const recordingDownloadLink = "https://download-link.com";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const booker = getBooker({
@@ -262,9 +261,6 @@ describe("Handler: /api/recorded-daily-video", () => {
vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID");
vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK);
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link: recordingDownloadLink,
});
const { req } = createMocks({
method: "POST",
@@ -291,7 +287,7 @@ describe("Handler: /api/recorded-daily-video", () => {
uid: bookingUid,
downloadLinks: {
transcription: TRANSCRIPTION_ACCESS_LINK.transcription,
recording: recordingDownloadLink,
recording: `${WEBAPP_URL}/api/video/recording?token=MOCK_TOKEN`,
},
organizer: {
email: organizer.email,
+72 -5
View File
@@ -12,12 +12,62 @@ import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
import { isENVDev } from "@calcom/lib/env";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
};
const shouldEnableRecordButton = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan) return false;
if (!calVideoSettings) return true;
if (isOrganizer) {
return !calVideoSettings.disableRecordingForOrganizer;
}
return !calVideoSettings.disableRecordingForGuests;
};
const checkIfUserIsHost = async ({
booking,
sessionUserId,
}: {
booking: {
user: { id: number } | null;
eventTypeId: number | undefined;
};
sessionUserId?: number;
}) => {
if (!sessionUserId) return false;
if (!booking.eventTypeId) {
return booking.user?.id === sessionUserId;
}
const eventType = await EventTypeRepository.findByIdWithUserAccess({
id: booking.eventTypeId,
userId: sessionUserId,
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
@@ -107,13 +157,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
const sessionUserId = !!session?.user?.impersonatedBy ? session.user.impersonatedBy.id : session?.user.id;
const isOrganizer = await checkIfUserIsHost({
booking: {
eventTypeId: bookingObj.eventType?.id,
user: bookingObj.user,
},
sessionUserId,
});
// set meetingPassword for guests
if (sessionUserId !== bookingObj.user?.id) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
videoReferencePassword,
sessionUserId
);
if (!isOrganizer) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
meetingToken: videoReferencePassword,
userId: sessionUserId,
});
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = guestMeetingPassword;
@@ -141,6 +198,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
const showRecordingButton = shouldEnableRecordButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
});
return {
props: {
meetingUrl: videoReference.meetingUrl ?? "",
@@ -161,6 +224,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
calVideoLogo,
displayLogInOverlay,
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
},
};
}
@@ -37,7 +37,13 @@ import { STEPS } from "~/apps/installation/[[...step]]/constants";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<
EventTypeModel,
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
| "metadata"
| "schedulingType"
| "slug"
| "requiresConfirmation"
| "position"
| "destinationCalendar"
| "calVideoSettings"
> & {
selected: boolean;
locations: LocationFormValues["locations"];
@@ -9,10 +9,10 @@ import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/components/icon";
import { Select } from "@calcom/ui/components/form";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
export default function Authorize() {
const { t } = useLocale();
@@ -39,7 +39,8 @@ export default function Authorize() {
}
);
const { data, isPending: isPendingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
const { data, isPending: isPendingProfiles } =
trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery();
const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({
onSuccess: (data) => {
@@ -217,6 +217,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const internalNotes = await getInternalNotePresets(eventType.team?.id ?? eventType.parent?.teamId ?? null);
// Filter out organizer information if hideOrganizerEmail is true
const sanitizedPreviousBooking =
eventType.hideOrganizerEmail &&
previousBooking &&
previousBooking.rescheduledBy === bookingInfo.user?.email
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
: previousBooking;
return {
props: {
orgSlug: currentOrgDomain,
@@ -227,7 +235,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
dynamicEventName: bookingInfo?.eventType?.eventName || "",
bookingInfo,
previousBooking,
previousBooking: sanitizedPreviousBooking,
paymentStatus: payment,
...(tz && { tz }),
userTimeFormat,
@@ -26,7 +26,7 @@ function ConnectAndJoin() {
const session = useSession();
const isUserPartOfOrg = session.status === "authenticated" && !!session.data.user?.org;
const mutation = trpc.viewer.connectAndJoin.useMutation({
const mutation = trpc.viewer.loggedInViewerRouter.connectAndJoin.useMutation({
onSuccess: (res) => {
if (res.meetingUrl && !res.isBookingAlreadyAcceptedBySomeoneElse) {
router.push(res.meetingUrl);
@@ -259,7 +259,7 @@ export const InfiniteEventTypeList = ({
const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState<Record<string, number>>({});
const utils = trpc.useUtils();
const mutation = trpc.viewer.eventTypeOrder.useMutation({
const mutation = trpc.viewer.loggedInViewerRouter.eventTypeOrder.useMutation({
onError: async (err) => {
console.error(err.message);
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
@@ -758,7 +758,7 @@ export const InfiniteEventTypeList = ({
)}
<DropdownMenuSeparator />
{!isManagedEventType && (
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between rounded-b-lg px-4 py-2 transition">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
@@ -121,7 +121,7 @@ const ProfileView = () => {
}
},
});
const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({
const unlinkConnectedAccountMutation = trpc.viewer.loggedInViewerRouter.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
@@ -131,7 +131,7 @@ const ProfileView = () => {
},
});
const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({
const addSecondaryEmailMutation = trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
onSuccess: (res) => {
setShowSecondaryEmailModalOpen(false);
setNewlyAddedSecondaryEmail(res?.data?.email);
@@ -3,6 +3,7 @@
import type { DailyCall } from "@daily-co/daily-js";
import DailyIframe from "@daily-co/daily-js";
import { DailyProvider } from "@daily-co/daily-react";
import { useDailyEvent } from "@daily-co/daily-react";
import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
@@ -33,6 +34,8 @@ export default function JoinCall(props: PageProps) {
calVideoLogo,
displayLogInOverlay,
loggedInUserName,
showRecordingButton,
rediectAttendeeToOnExit,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
@@ -65,12 +68,16 @@ export default function JoinCall(props: PageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
...(showRecordingButton
? {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
}
: {}),
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
@@ -126,7 +133,7 @@ export default function JoinCall(props: PageProps) {
</div>
{displayLogInOverlay && <LogInOverlay isLoggedIn={!!loggedInUserName} bookingUid={booking.uid} />}
<VideoMeetingInfo booking={booking} />
<VideoMeetingInfo booking={booking} rediectAttendeeToOnExit={rediectAttendeeToOnExit} />
</DailyProvider>
);
}
@@ -255,16 +262,23 @@ export function LogInOverlay(props: LogInOverlayProps) {
interface VideoMeetingInfo {
booking: PageProps["booking"];
rediectAttendeeToOnExit?: string | null;
}
export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false);
const { booking } = props;
const { booking, rediectAttendeeToOnExit } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
useDailyEvent("left-meeting", () => {
if (rediectAttendeeToOnExit) {
window.location.href = rediectAttendeeToOnExit;
}
});
return (
<>
<aside
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.3.4",
"version": "5.3.7",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -194,7 +194,7 @@
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
"typescript": "^5.8.3"
},
"nextBundleAnalysis": {
"budget": 358400,
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { loggedInViewerRouter } from "@calcom/trpc/server/routers/loggedInViewer/_router";
export default createNextApiHandler(loggedInViewerRouter);
+61
View File
@@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { randomString } from "@calcom/lib/random";
import { SchedulingType } from "@calcom/prisma/client";
import type { Schedule, TimeRange } from "@calcom/types/schedule";
@@ -694,3 +695,63 @@ test("Should throw error when both seatsPerTimeSlot and recurringEvent are set",
"Could not book the meeting. Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring."
);
});
test.describe("GTM container", () => {
test.beforeEach(async ({ page, users }) => {
await users.create();
});
test("global GTM should not be loaded on private booking link", async ({ page, users, emails, prisma }) => {
const [user] = users.get();
const eventType = await user.getFirstEventAsOwner();
const eventWithPrivateLink = await prisma.eventType.update({
where: {
id: eventType.id,
},
data: {
hashedLink: {
create: [
{
link: generateHashedLink(eventType.id),
},
],
},
},
include: {
hashedLink: true,
},
});
const getScheduleRespPromise = page.waitForResponse(
(response) => response.url().includes("getSchedule") && response.status() === 200
);
await page.goto(`/d/${eventWithPrivateLink.hashedLink[0]?.link}/${eventWithPrivateLink.slug}`);
await page.waitForLoadState("domcontentloaded");
await getScheduleRespPromise;
const injectedScript = page.locator('script[id="injected-body-script"]');
await expect(injectedScript).not.toBeAttached();
});
test("global GTM should be loaded on non-booking pages", async ({ page, users }) => {
test.skip(!process.env.NEXT_PUBLIC_BODY_SCRIPTS, "Skipping test as NEXT_PUBLIC_BODY_SCRIPTS is not set");
const [user] = users.get();
await user.apiLogin();
// Go to /insights page and wait for one of the common API call to complete
const eventsByStatusRespPromise = page.waitForResponse(
(response) => response.url().includes("getEventTypesFromGroup") && response.status() === 200
);
await page.goto(`/insights`);
await page.waitForLoadState("domcontentloaded");
await eventsByStatusRespPromise;
const injectedScript = page.locator('script[id="injected-body-script"]');
await expect(injectedScript).toBeAttached();
const scriptContent = await injectedScript.textContent();
expect(scriptContent).toContain("googletagmanager");
});
});
+4
View File
@@ -1,6 +1,10 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
// Helper function to get text within a specific table column
export const getByTableColumnText = (page: Page, columnId: string, text: string) =>
page.locator(`[data-testid="data-table-td-${columnId}"]`).getByText(text);
/**
* Add a filter from the filter dropdown
*/
+13 -12
View File
@@ -10,6 +10,7 @@ import {
listSegments,
clearFilters,
openSegmentSubmenu,
getByTableColumnText,
} from "./filter-helpers";
import { test } from "./lib/fixtures";
@@ -58,20 +59,20 @@ test.describe("Filter Segment Functionality", () => {
await test.step("Can apply and save a role filter as a segment", async () => {
await applySelectFilter(page, "role", "admin");
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
await createFilterSegment(page, segmentName);
await clearFilters(page);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeVisible();
await selectSegment(page, segmentName);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
});
await test.step("Can delete a filter segment", async () => {
@@ -119,8 +120,8 @@ test.describe("Filter Segment Functionality", () => {
await selectSegment(page, segmentName);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
await deleteSegment(page, segmentName);
});
@@ -167,8 +168,8 @@ test.describe("Filter Segment Functionality", () => {
await clearFilters(page);
await selectSegment(page, segmentName);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
});
await test.step("Regular member can see but not modify team segments", async () => {
@@ -193,8 +194,8 @@ test.describe("Filter Segment Functionality", () => {
await expect(dataTable).toBeVisible();
await selectSegment(page, "Team Admin Filter");
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
await openSegmentSubmenu(page, segmentName);
await expect(
+1 -1
View File
@@ -395,7 +395,7 @@ export const createUsersFixture = (
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: "https://cal.com" },
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
@@ -11,6 +11,7 @@ import {
applyTextFilter,
applyNumberFilter,
selectOptionValue,
getByTableColumnText,
} from "./filter-helpers";
import { test } from "./lib/fixtures";
@@ -186,8 +187,8 @@ test.describe("Insights > Routing Filters", () => {
await applySelectFilter(page, "bookingUserId", user1Name);
await expect(page.getByText("John Doe")).toBeVisible();
await expect(page.getByText("Jane Smith")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "John Doe")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Jane Smith")).toBeHidden();
await prisma.booking.delete({ where: { id: booking1.id } });
await prisma.booking.delete({ where: { id: booking2.id } });
@@ -291,8 +292,8 @@ test.describe("Insights > Routing Filters", () => {
await page.getByPlaceholder("Filter attendees by name or email").fill("John");
await page.keyboard.press("Enter");
await expect(page.getByText("Response 1")).toBeVisible();
await expect(page.getByText("Response 2")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "Response 1")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Response 2")).toBeHidden();
await clearFilters(page);
await addFilter(page, "bookingAttendees");
@@ -300,8 +301,8 @@ test.describe("Insights > Routing Filters", () => {
await page.getByPlaceholder("Filter attendees by name or email").fill("jane.smith@example");
await page.keyboard.press("Enter");
await expect(page.getByText("Response 2")).toBeVisible();
await expect(page.getByText("Response 1")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "Response 2")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Response 1")).toBeHidden();
await clearFilters(page);
await addFilter(page, "bookingAttendees");
@@ -309,8 +310,8 @@ test.describe("Insights > Routing Filters", () => {
await page.getByPlaceholder("Filter attendees by name or email").fill("JANE");
await page.keyboard.press("Enter");
await expect(page.getByText("Response 2")).toBeVisible();
await expect(page.getByText("Response 1")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "Response 2")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Response 1")).toBeHidden();
await prisma.booking.delete({ where: { id: booking1.id } });
await prisma.booking.delete({ where: { id: booking2.id } });
@@ -392,26 +393,26 @@ test.describe("Insights > Routing Filters", () => {
await applyTextFilter(page, fieldId, "Contains", "test");
await expect(page.getByText("This is a test description")).toBeVisible();
await expect(page.getByText("Another description for testing")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeVisible();
await clearFilters(page);
await applyTextFilter(page, fieldId, "Is", "This is a test description");
await expect(page.getByText("This is a test description")).toBeVisible();
await expect(page.getByText("Another description for testing")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeHidden();
await clearFilters(page);
await applyTextFilter(page, fieldId, "Is empty");
await expect(page.getByText("This is a test description")).toBeHidden();
await expect(page.getByText("Another description for testing")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeHidden();
await clearFilters(page);
await applyTextFilter(page, fieldId, "Contains", "TEST");
await expect(page.getByText("This is a test description")).toBeVisible();
await expect(page.getByText("Another description for testing")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeVisible();
});
test("NUMBER field: should filter with different number operators", async ({
@@ -489,26 +490,23 @@ test.describe("Insights > Routing Filters", () => {
await applyNumberFilter(page, fieldId, "=", 3);
const getByNumber = (number: number) =>
page.locator(`[data-testid="data-table-td-${fieldId}"]`).getByText(String(number));
await expect(getByNumber(3)).toBeVisible();
await expect(getByNumber(1)).toBeHidden();
await expect(getByNumber(5)).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "3")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "1")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "5")).toBeHidden();
await clearFilters(page);
await applyNumberFilter(page, fieldId, ">", 3);
await expect(getByNumber(5)).toBeVisible();
await expect(getByNumber(1)).toBeHidden();
await expect(getByNumber(3)).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "5")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "1")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "3")).toBeHidden();
await clearFilters(page);
await applyNumberFilter(page, fieldId, "≤", 3);
await expect(getByNumber(1)).toBeVisible();
await expect(getByNumber(3)).toBeVisible();
await expect(getByNumber(5)).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "1")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "3")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "5")).toBeHidden();
});
test("SINGLE_SELECT and MULTI_SELECT fields: should filter correctly", async ({
@@ -620,19 +618,16 @@ test.describe("Insights > Routing Filters", () => {
await page.goto(`/insights/routing`);
const getByColumnValue = (columnId: string, value: string) =>
page.locator(`[data-testid="data-table-td-${columnId}"]`).getByText(value);
await applySelectFilter(page, locationFieldId, "New York");
await expect(getByColumnValue(locationFieldId, "New York")).toBeVisible();
await expect(getByColumnValue(locationFieldId, "London")).toBeHidden();
await expect(getByTableColumnText(page, locationFieldId, "New York")).toBeVisible();
await expect(getByTableColumnText(page, locationFieldId, "London")).toBeHidden();
await clearFilters(page);
await applySelectFilter(page, skillFieldId, "JavaScript");
await applySelectFilter(page, skillFieldId, "TypeScript");
await expect(getByColumnValue(skillFieldId, "JavaScript")).toBeVisible();
await expect(getByColumnValue(skillFieldId, "TypeScript")).toBeVisible();
await expect(getByColumnValue(skillFieldId, "React")).toBeHidden();
await expect(getByTableColumnText(page, skillFieldId, "JavaScript")).toBeVisible();
await expect(getByTableColumnText(page, skillFieldId, "TypeScript")).toBeVisible();
await expect(getByTableColumnText(page, skillFieldId, "React")).toBeHidden();
});
test("createdAt filter: should filter by date range", async ({ page, users, routingForms, prisma }) => {
@@ -698,14 +693,14 @@ test.describe("Insights > Routing Filters", () => {
await page.getByTestId("date-range-options-w").click(); // Last 7 Days
await page.keyboard.press("Escape");
await expect(page.getByText("Recent Response")).toBeVisible();
await expect(page.getByText("Old Response")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "Recent Response")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Old Response")).toBeHidden();
await openFilter(page, "createdAt");
await page.getByTestId("date-range-options-t").click(); // Last 30 Days
await page.keyboard.press("Escape");
await expect(page.getByText("Recent Response")).toBeVisible();
await expect(page.getByText("Old Response")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Recent Response")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Old Response")).toBeVisible();
});
});
@@ -44,6 +44,10 @@
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
"event_reassigned_subject": "Reassigned: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
"customize_calvideo_settings": "Customize Cal Video Settings",
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
"disable_recording_for_guests": "Disable recording for guests",
"disable_recording_for_organizer": "Disable recording for organizer",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
@@ -2006,6 +2010,7 @@
"add_a_booking_question": "Add a question",
"identifier": "Identifier",
"duplicate_email": "Email is duplicate",
"enter_redirect_url_on_exit_description": "Enter a redirect URL to which the attendee will be sent once they leave the meeting.",
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment is on its way.",
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
@@ -2911,6 +2916,7 @@
"routing_form_insights_booking_at": "Booking At",
"routing_form_insights_submitted_at": "Submitted At",
"copy_redirect_url": "Copy Redirect URL",
"redirect_url_on_exit": "Redirect URL on Exit",
"total_bookings_per_period": "Total Bookings per period",
"routed_to_per_period": "Routed to per period",
"per_day": "Per day",
+282 -117
View File
@@ -430,9 +430,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -440,9 +443,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -628,9 +633,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -638,9 +646,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -1466,6 +1476,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -1850,9 +1870,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -1860,9 +1883,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2523,9 +2548,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2533,9 +2561,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2599,9 +2629,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2609,9 +2642,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2739,9 +2774,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2749,9 +2787,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4001,9 +4041,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4011,9 +4054,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4085,9 +4130,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4095,9 +4143,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -5208,6 +5258,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -5427,9 +5487,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5437,9 +5500,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -5689,9 +5754,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5699,9 +5767,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -6108,9 +6178,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -6118,9 +6191,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -6519,6 +6594,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -6929,7 +7014,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Reassign a booking to auto-selected host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -6978,7 +7063,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -8328,9 +8413,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -8338,9 +8426,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -8587,6 +8677,31 @@
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
],
"responses": {
@@ -9215,9 +9330,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9225,9 +9343,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -9273,9 +9393,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9283,9 +9406,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -11043,9 +11168,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11053,9 +11181,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -11416,9 +11546,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11426,9 +11559,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -11474,9 +11609,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11484,9 +11622,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -11780,9 +11920,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11790,9 +11933,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -11830,9 +11975,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11840,9 +11988,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12004,9 +12154,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12014,9 +12167,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -14135,6 +14290,7 @@
},
"address": {
"type": "string",
"minLength": 1,
"example": "123 Example St, City, Country"
},
"public": {
@@ -20127,6 +20283,66 @@
},
"required": ["status", "data"]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123,
"minimum": 0
},
"remainingItems": {
"type": "number",
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103,
"minimum": 0
},
"returnedItems": {
"type": "number",
"description": "The number of items returned in the current page.",
"example": 10
},
"itemsPerPage": {
"type": "number",
"description": "The maximum number of items requested per page.",
"example": 10,
"minimum": 1
},
"currentPage": {
"type": "number",
"description": "The current page number being returned.",
"example": 2,
"minimum": 1
},
"totalPages": {
"type": "number",
"description": "The total number of pages available.",
"example": 13,
"minimum": 0
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"returnedItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetManagedOrganizationsOutput": {
"type": "object",
"properties": {
@@ -20140,9 +20356,12 @@
"items": {
"$ref": "#/components/schemas/ManagedOrganizationOutput"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationMetaDto"
}
},
"required": ["status", "data"]
"required": ["status", "data", "pagination"]
},
"UpdateOrganizationInput": {
"type": "object",
@@ -22158,60 +22377,6 @@
},
"required": ["status", "data"]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"minimum": 0,
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123
},
"remainingItems": {
"type": "number",
"minimum": 0,
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103
},
"itemsPerPage": {
"type": "number",
"minimum": 1,
"description": "The maximum number of items requested per page.",
"example": 10
},
"currentPage": {
"type": "number",
"minimum": 1,
"description": "The current page number being returned.",
"example": 2
},
"totalPages": {
"type": "number",
"minimum": 0,
"description": "The total number of pages available.",
"example": 13
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetBookingsOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -16,9 +16,6 @@ To test API in your local instance, you have the following pre-requisites:
1a1f8138-0bfc-4f37-b4af-1e24fd145839
```
</Step>
<Step title="Install API submodule">
Next, you need to install API submodule by following the instructions provided in [API submodule](/developing/local-development#api)
</Step>
<Step title="Start development server and create API key">
Start the cal.com server using `yarn dev` on localhost and create the test API keys by visiting `/settings/developer/api-keys`
</Step>
+4 -1
View File
@@ -29,11 +29,14 @@ In order for atoms to handle scheduling on behalf of your users, you have to cre
#### What is a “Managed user”?
It is a representation of your user within our database containing basic information like email and is used to manage the setup of Google Calendar.
After creating a “managed user” you will receive the user ID, an access and a refresh token that are used by atoms to handle scheduling and can be used to modify the “managed user” information, so make sure to store the tokens and the [cal.com](http://cal.com) user ID in your database. Also, you will have to connect each user on your end with the access and refresh tokens by adding new properties on your User model in database that store the tokens, for example.
After creating a “managed user” you will receive the user ID, an access and a refresh token that are used by atoms to handle scheduling and can be used to modify the “managed user” information, so make sure to store the tokens and the [cal.com](https://cal.com) user ID in your database. Also, you will have to connect each user on your end with the access and refresh tokens by adding new properties on your User model in database that store the tokens, for example.
#### What is it not?
It's important to clarify that a "managed user" is completely independent of a regular user account on cal.com, meaning you don't need your users to register on cal.com web application.
❗Managed users do not get a public cal.com page like normal cal.com users do aka if you create a managed user the managed user won't have cal.com/managed-user page, because the point of platform solution is to integrate scheduling into your platform,
so instead the managed user public page will be at your-platform.com/managed-user and there you fetch event types of the managed user using our api or react hooks and display them.
### Create managed users via our API
We now need the OAuth client’s “client ID” and “client secret” that you can find in [https://app.cal.com/settings/organizations/platform/oauth-clients](https://app.cal.com/settings/organizations/platform/oauth-clients).
+1 -19
View File
@@ -29,12 +29,6 @@ Note: `docker compose` without the hyphen is now the primary method of using doc
cd calcom-docker
```
3. Update the calcom submodule
```
git submodule update --remote --init
```
4. Rename `.env.example` to `.env` and then update `.env`
5. Build and start Cal.com via docker compose
@@ -67,20 +61,8 @@ These variables must be provided at the time of the docker build, and can be pro
* NEXTAUTH_SECRET
### Git Submodules
This repository uses a git submodule.
To update the calcom submodule, use the following command:
```
git submodule update --remote --init
```
For more advanced usage, please refer to the git documentation: [https://git-scm.com/book/en/v2/Git-Tools-Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules)
### Troubleshooting
* SSL edge termination: If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
* Failed to commit changes: Invalid 'prisma.user.create()': Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
* Failed to commit changes: Invalid 'prisma.user.create()': Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
@@ -4,13 +4,13 @@ title: "How to sync third party apps"
<Steps>
<Step title="Add the required variables in your `.env` file">
1. `CALCOM_WEBHOOK_SECRET`
1. `CALCOM_WEBHOOK_SECRET`
- You can generate this by running `openssl rand -base64 32`. This is required when sending 3rd party app credentials from your platform to your instance of Cal.com.
2. On your self-hosted instance of Cal.com visit settings/admin/apps under an admin account. Here you can enable/disable apps on Cal.com and set the app keys (client id, client secret, etc.). These keys should match the ones on your platform.
3. `CALCOM_WEBHOOK_HEADER_NAME`
- The header name is expected to contain the webhook secret. The default is `calcom-webhook-secret`
3. `CALCOM_WEBHOOK_HEADER_NAME`
- The header name is expected to contain the webhook secret. The default is `calcom-webhook-secret`
5. `CALCOM_CREDENTIAL_SYNC_ENDPOINT`
- The endpoint on your platform that your instance of [Cal.com](http://Cal.com) will make a request to if the 3rd party app credentials are expired.
- The endpoint on your platform that your instance of [Cal.com](https://Cal.com) will make a request to if the 3rd party app credentials are expired.
5. `CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY`
- When sending 3rd party app credentials between your platform and your instance Cal.com, we expect these to be encrypted using AES256. When you encrypt the 3rd party app credentials, ensure the same key is used.
@@ -23,7 +23,7 @@ title: "How to sync third party apps"
// UserId of the Cal.com user
userId: number;
// The app slug that is on Cal.com.
// The app slug that is on Cal.com.
// Can be found in the Cal.com database in the App table
appSlug: string;
@@ -35,7 +35,7 @@ title: "How to sync third party apps"
```
</Step>
<Step title="Refreshing credentials">
When [Cal.com](http://Cal.com) needs to refresh the app credentials it will make a request to `CALCOM_CREDENTIAL_SYNC_ENDPOINT`. The request contains the following.
When [Cal.com](https://Cal.com) needs to refresh the app credentials it will make a request to `CALCOM_CREDENTIAL_SYNC_ENDPOINT`. The request contains the following.
```javascript
{
@@ -47,7 +47,7 @@ title: "How to sync third party apps"
</Step>
</Steps>
<Note>
This only works if you have integrated [Cal.com](http://cal.com/) into your platform. Users **must** consent to give access to your platform, and you are simply using [Cal.com](http://cal.com/)'s code within your platform.
</Note>
This only works if you have integrated [Cal.com](https://cal.com/) into your platform. Users **must** consent to give access to your platform, and you are simply using [Cal.com](http://cal.com/)'s code within your platform.
</Note>
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
# Skip if `.gitmodules` exists
[ -f .gitmodules ] && {
echo ".gitmodules already initialized"
exit 0
}
./git-setup.sh website console
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
# If no project name is given
if [ $# -eq 0 ]; then
# Display usage and stop
echo "Usage: git-setup.sh <console,website>"
exit 1
fi
# Get remote url to support either https or ssh
remote_url=$(echo $(git config --get remote.origin.url) | sed 's![^/]*$!!')
# Loop through the requested modules
for module in "$@"; do
echo "Setting up '$module' module..."
# Set the project git URL
project=$remote_url$module.git
# Check if we have access to the module
if [ "$(git ls-remote "$project" 2>/dev/null)" ]; then
echo "You have access to '${module}'"
# Create the .gitmodules file if it doesn't exist
([ -e ".gitmodules" ] || touch ".gitmodules") && [ ! -w ".gitmodules" ] && echo cannot write to .gitmodules && exit 1
# Prevents duplicate entries
git config -f .gitmodules --unset-all "submodule.apps/$module.branch"
# Add the submodule
git submodule add --force $project "apps/$module"
# Determine the branch based on module
branch="main"
if [ "$module" = "website" ]; then
branch="production"
fi
# Set the default branch
git config -f .gitmodules --add "submodule.apps/$module.branch" ${branch}
# Update to the latest of branch in that submodule
cd apps/$module && git pull origin ${branch} && cd ../..
# We forcefully added the subdmoule which was in .gitignore, so unstage it.
git restore --staged apps/$module
else
echo "You don't have access to: '${module}' module."
fi
done
git restore --staged .gitmodules
+1 -1
View File
@@ -108,7 +108,7 @@
"prismock": "^1.33.4",
"resize-observer-polyfill": "^1.5.1",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"vitest": "^2.1.1",
"vitest-fetch-mock": "^0.3.0",
"vitest-mock-extended": "^2.0.2"
+1 -1
View File
@@ -31,6 +31,6 @@
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
"typescript": "^5.8.3"
}
}
@@ -189,6 +189,49 @@ describe("Credential Sync Disabled", () => {
expiry_date: 0,
});
});
test("`fetchNewTokenObject` is called if token is about to expire. Also, `updateTokenObject` is called with currentTokenObject and newTokenObject merged", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const currentTokenObject = getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expiry_date: Date.now() - 2 * 1000,
});
const newTokenObjectInResponse = getDummyTokenObject();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: newTokenObjectInResponse }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: currentTokenObject,
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
expect(updateTokenObject).toHaveBeenCalledWith({
...currentTokenObject,
...newTokenObjectInResponse,
// Consider the token as expired as newTokenObjectInResponse didn't have expiry
expiry_date: 0,
});
});
});
describe("checking using expires_in", () => {
@@ -285,7 +328,7 @@ describe("Credential Sync Disabled", () => {
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expires_in: Date.now() / 1000 + 5,
expires_in: Date.now() / 1000 + 10,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
@@ -388,7 +431,7 @@ describe("Credential Sync Disabled", () => {
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject: async () => {
throw new Error("testError");
throw new Error("fetchNewTokenObject error");
},
isTokenObjectUnusable: async () => {
return null;
@@ -403,7 +446,7 @@ describe("Credential Sync Disabled", () => {
expect(async () => {
return auth.getTokenObjectOrFetch();
}).rejects.toThrowError("Invalid token response");
}).rejects.toThrowError("fetchNewTokenObject error");
});
test("if fetchNewTokenObject throws error that's handled by isTokenObjectUnusable then auth.getTokenObjectOrFetch would still throw error but a different one as access_token won't be available", async () => {
@@ -421,7 +464,7 @@ describe("Credential Sync Disabled", () => {
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject: async () => {
throw new Error("testError");
throw new Error("fetchNewTokenObject error");
},
isTokenObjectUnusable: async () => {
return {
@@ -438,7 +481,42 @@ describe("Credential Sync Disabled", () => {
expect(async () => {
return auth.getTokenObjectOrFetch();
}).rejects.toThrowError("Invalid token response");
}).rejects.toThrowError("fetchNewTokenObject error");
});
test("when currentTokenObject is not set, but getCurrentTokenObject is set", async () => {
const userId = 1;
const getCurrentTokenObject = vi.fn().mockResolvedValue(getDummyTokenObject());
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
isTokenExpiring: async () => {
return false;
},
isAccessTokenUnusable: async () => {
return null;
},
getCurrentTokenObject: getCurrentTokenObject,
fetchNewTokenObject: async () => {
return generateJsonResponse({ json: getDummyTokenObject() });
},
updateTokenObject: vi.fn(),
invalidateTokenObject: vi.fn(),
expireAccessToken: vi.fn(),
isTokenObjectUnusable: async () => {
return null;
},
});
const tokenObject = await auth.getTokenObjectOrFetch();
expect(getCurrentTokenObject).toHaveBeenCalled();
expect(tokenObject).toEqual({
token: expect.objectContaining(getDummyTokenObject()),
isUpdated: false,
});
});
});
+73 -21
View File
@@ -58,20 +58,24 @@ type CredentialSyncVariables = {
APP_CREDENTIAL_SHARING_ENABLED: boolean;
};
type CurrentTokenObject = z.infer<typeof OAuth2UniversalSchema>;
type GetCurrentTokenObject = () => Promise<CurrentTokenObject>;
/**
* Manages OAuth2.0 tokens for an app and resourceOwner
* If expiry_date or expires_in isn't provided in token then it is considered expired immediately(if credential sync is not enabled)
* If credential sync is enabled, the token is considered expired after a year. It is expected to be refreshed by the API request from the credential source(as it knows when the token is expired)
*/
export class OAuthManager {
private currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
protected currentTokenObject: CurrentTokenObject | null;
private getCurrentTokenObject: GetCurrentTokenObject | null;
private resourceOwner: ResourceOwner;
private appSlug: string;
private fetchNewTokenObject: FetchNewTokenObject;
private updateTokenObject: UpdateTokenObject;
private isTokenObjectUnusable: isTokenObjectUnusable;
private isAccessTokenUnusable: isAccessTokenUnusable;
private isTokenExpired: IsTokenExpired;
private isTokenExpiring: IsTokenExpired;
private invalidateTokenObject: InvalidateTokenObject;
private expireAccessToken: ExpireAccessToken;
private credentialSyncVariables: CredentialSyncVariables;
@@ -79,6 +83,7 @@ export class OAuthManager {
private autoCheckTokenExpiryOnRequest: boolean;
constructor({
getCurrentTokenObject,
resourceOwner,
appSlug,
currentTokenObject,
@@ -90,14 +95,22 @@ export class OAuthManager {
expireAccessToken,
credentialSyncVariables,
autoCheckTokenExpiryOnRequest = true,
isTokenExpired = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
isTokenExpiring = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
// TODO: Make it configurable later
// 5 seconds before expiry so that we can refresh the token before any request is made with the expired token
const expireThreshold = 5000;
const isGoingToExpire = getExpiryDate() - expireThreshold <= Date.now();
log.debug(
"isTokenExpired called",
safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() })
"isTokenExpiring",
safeStringify({
isGoingToExpire,
expiry_date: token.expiry_date,
expires_in: token.expires_in,
currentTime: Date.now(),
expireThreshold,
})
);
return getExpiryDate() <= Date.now();
return isGoingToExpire;
function isRelativeToEpoch(relativeTimeInSeconds: number) {
return relativeTimeInSeconds > 1000000000; // If it is more than 2001-09-09 it can be considered relative to epoch. Also, that is more than 30 years in future which couldn't possibly be relative to current time
}
@@ -137,11 +150,14 @@ export class OAuthManager {
/**
* The current token object.
*/
currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
currentTokenObject?: CurrentTokenObject;
getCurrentTokenObject?: GetCurrentTokenObject;
/**
* The unique identifier of the app that the token is for. It is required to do credential syncing in self-hosting
*/
appSlug: string;
/**
*
* It could be null in case refresh_token isn't available. This is possible when credential sync happens from a third party who doesn't want to share refresh_token and credential syncing has been disabled after the sync has happened.
@@ -172,15 +188,19 @@ export class OAuthManager {
/**
* If there is a different way to check if the token is expired(and not the standard way of checking expiry_date)
*/
isTokenExpired?: IsTokenExpired;
isTokenExpiring?: IsTokenExpired;
}) {
if (!getCurrentTokenObject && !currentTokenObject) {
throw new Error("One of getCurrentTokenObject or currentTokenObject is required");
}
this.resourceOwner = resourceOwner;
this.currentTokenObject = currentTokenObject;
this.currentTokenObject = currentTokenObject ?? null;
this.getCurrentTokenObject = getCurrentTokenObject ?? null;
this.appSlug = appSlug;
this.fetchNewTokenObject = fetchNewTokenObject;
this.isTokenObjectUnusable = isTokenObjectUnusable;
this.isAccessTokenUnusable = isAccessTokenUnusable;
this.isTokenExpired = isTokenExpired;
this.isTokenExpiring = isTokenExpiring;
this.invalidateTokenObject = invalidateTokenObject;
this.expireAccessToken = expireAccessToken;
this.credentialSyncVariables = credentialSyncVariables;
@@ -205,14 +225,28 @@ export class OAuthManager {
return !response.ok || response.status < 200 || response.status >= 300;
}
/**
* Gets the current token object as is if not expired.
* If expired, it refreshes the token and returns the new token object.
* It also calls the `updateTokenObject` to update the token object in the database if it is changed.
*/
public async getTokenObjectOrFetch() {
const myLog = log.getSubLogger({
prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`],
});
const isExpired = await this.isTokenExpired(this.currentTokenObject);
let currentTokenObject;
if (this.currentTokenObject) {
currentTokenObject = this.currentTokenObject;
} else if (this.getCurrentTokenObject) {
this.currentTokenObject = currentTokenObject = await this.getCurrentTokenObject();
} else {
throw new Error("Neither currentTokenObject nor getCurrentTokenObject is set");
}
const isExpired = await this.isTokenExpiring(currentTokenObject);
myLog.debug(
"getTokenObjectOrFetch called",
safeStringify({
currentTokenObjectHasAccessToken: !!currentTokenObject.access_token,
isExpired,
resourceOwner: this.resourceOwner,
})
@@ -220,14 +254,16 @@ export class OAuthManager {
if (!isExpired) {
myLog.debug("Token is not expired. Returning the current token object");
return { token: this.normalizeNewlyReceivedToken(this.currentTokenObject), isUpdated: false };
return { token: this.normalizeNewlyReceivedToken(currentTokenObject), isUpdated: false };
} else {
const token = {
// Keep the old token object as it is, as some integrations don't send back all the props e.g. refresh_token isn't sent again by Google Calendar
// It also allows any other properties set to be retained.
// Let's not use normalizedCurrentTokenObject here as `normalizeToken` could possible be not idempotent
...this.currentTokenObject,
...this.normalizeNewlyReceivedToken(await this.refreshOAuthToken()),
...currentTokenObject,
...this.normalizeNewlyReceivedToken(
await this.refreshOAuthToken({ refreshToken: currentTokenObject.refresh_token ?? null })
),
};
myLog.debug("Token is expired. So, returning new token object");
this.currentTokenObject = token;
@@ -268,7 +304,6 @@ export class OAuthManager {
) {
let response;
const myLog = log.getSubLogger({ prefix: ["request"] });
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
@@ -284,6 +319,7 @@ export class OAuthManager {
response = handleFetchError(e);
}
} else {
this.assertCurrentTokenObjectIsSet();
const { url, options } = customFetchOrUrlAndOptions;
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
@@ -330,6 +366,16 @@ export class OAuthManager {
return { tokenStatus: tokenStatus, json };
}
/**
* currentTokenObject is set through getTokenObjectOrFetch call
*/
private assertCurrentTokenObjectIsSet(): asserts this is this & {
currentTokenObject: CurrentTokenObject;
} {
if (!this.currentTokenObject) {
throw new Error("currentTokenObject is not set");
}
}
/**
* It doesn't automatically detect the response for tokenObject and accessToken becoming invalid
* Could be used when you expect a possible non JSON response as well.
@@ -340,6 +386,9 @@ export class OAuthManager {
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
// Either `getTokenObjectOrFetch` has been called through autoCheckTokenExpiryOnRequest or through a direct call to it outside OAuthManager
// In both cases, `currentTokenObject` is set
this.assertCurrentTokenObjectIsSet();
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
@@ -409,10 +458,9 @@ export class OAuthManager {
}
// TODO: On regenerating access_token successfully, we should call makeTokenObjectValid(to counter invalidateTokenObject). This should fix stale banner in UI to reconnect when the connection is working
private async refreshOAuthToken() {
private async refreshOAuthToken({ refreshToken }: { refreshToken: string | null }) {
const myLog = log.getSubLogger({ prefix: ["refreshOAuthToken"] });
let response;
const refreshToken = this.currentTokenObject.refresh_token ?? null;
if (this.resourceOwner.id && this.useCredentialSync) {
if (
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET ||
@@ -468,9 +516,8 @@ export class OAuthManager {
const clonedResponse = response.clone();
myLog.info(
"Response from refreshOAuthToken",
"Response status from refreshOAuthToken",
safeStringify({
text: await clonedResponse.text(),
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
@@ -485,6 +532,11 @@ export class OAuthManager {
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
}
if (json && json.myFetchError) {
// Throw error back as it isn't a valid token response and we can't process it further
throw new Error(json.myFetchError);
}
const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json);
if (!parsedToken.success) {
myLog.error(
@@ -0,0 +1,54 @@
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
const log = logger.getSubLogger({
prefix: ["getCurrentTokenObject"],
});
function buildDummyTokenObjectForDelegationUserCredential() {
return {
access_token: "TOKEN_PLACEHOLDER_FOR_DELEGATION_CREDENTIAL",
};
}
/**
* OAuthManager helper to get the current token object. It decides to use the Credential that is passed or retrieve from db.
*/
export async function getCurrentTokenObject(
credential: Pick<CredentialForCalendarService, "key" | "id" | "delegatedToId" | "userId">
) {
let inDbCredential;
// CalendarService currently receives an in-memory delegation credential which is incapable to persist access token generated for a user.
// So, in this case we read Delegation User Credential from db separately if available. updateTokenObject will create new Delegation User Credential in db if not available.
if (credential.delegatedToId && isInMemoryDelegationCredential({ credentialId: credential.id })) {
if (!credential.userId) {
log.error("DelegationCredential: No user id found for delegation credential");
} else {
log.debug("Getting current token object for delegation credential");
const delegationUserCredentialInDb =
await CredentialRepository.findUniqueByUserIdAndDelegationCredentialId({
userId: credential.userId,
delegationCredentialId: credential.delegatedToId,
});
inDbCredential = delegationUserCredentialInDb;
if (!inDbCredential) {
log.error("getCurrentTokenObject: No delegation user credential found in db");
// We return a dummy token object. OAuthManager requires a token object that must have access_token.
// OAuthManager will help fetching new token object and then that would be stored in DB.
return buildDummyTokenObjectForDelegationUserCredential();
}
}
} else {
log.debug("Getting current token object for non delegation credential");
inDbCredential = credential;
}
if (!inDbCredential) {
throw new Error("getCurrentTokenObject: No delegation user credential found in db");
}
const currentTokenObject = getTokenObjectFromCredential(inDbCredential);
return currentTokenObject;
}
@@ -4,9 +4,8 @@ import type { CredentialPayload } from "@calcom/types/Credential";
import { OAuth2TokenResponseInDbSchema } from "./universalSchema";
export function getTokenObjectFromCredential(credential: CredentialPayload) {
export function getTokenObjectFromCredential(credential: Pick<CredentialPayload, "key" | "id">) {
const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key);
if (!parsedTokenResponse.success) {
logger.error(
"GoogleCalendarService-getTokenObjectFromCredential",
@@ -6,9 +6,10 @@ import {
} from "@calcom/lib/constants";
import { invalidateCredential } from "../invalidateCredential";
import { getCurrentTokenObject } from "./getCurrentTokenObject";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
import { markTokenAsExpired } from "./markTokenAsExpired";
import { updateTokenObject } from "./updateTokenObject";
import { updateTokenObject, updateTokenObjectInDb } from "./updateTokenObject";
export const credentialSyncVariables = {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
@@ -23,4 +24,6 @@ export const oAuthManagerHelper = {
invalidateCredential: invalidateCredential,
getTokenObjectFromCredential,
credentialSyncVariables,
updateTokenObjectInDb,
getCurrentTokenObject,
};
@@ -1,9 +1,16 @@
import type { Prisma } from "@prisma/client";
import type z from "zod";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import prisma from "@calcom/prisma";
import type { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
const log = logger.getSubLogger({ prefix: ["_utils", "oauth", "updateTokenObject"] });
/**
* @deprecated Use updateTokenObjectInDb instead
*/
export const updateTokenObject = async ({
tokenObject,
credentialId,
@@ -20,3 +27,67 @@ export const updateTokenObject = async ({
},
});
};
/**
* OAuthManager helper to update the token object in db.
*
* It ensures that the token goes in DB. For JWT flow, it also creates a delegation user credential if not present
*/
export const updateTokenObjectInDb = async (
args: {
tokenObject: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>;
} & (
| {
authStrategy: "jwt";
userId: number | null;
credentialType: string;
appId: string;
delegatedToId: string | null;
}
| {
authStrategy: "oauth";
credentialId: number;
}
)
) => {
const { tokenObject } = args;
if (args.authStrategy === "jwt") {
const { userId, delegatedToId, credentialType, appId } = args;
if (!userId) {
log.error("Cannot update token object in DB for Delegation as userId is not present");
return;
}
if (!delegatedToId) {
log.error("Cannot update token object in DB for Delegation as delegatedToId is not present");
return;
}
const updated = await CredentialRepository.updateWhereUserIdAndDelegationCredentialId({
userId,
delegationCredentialId: delegatedToId,
data: {
key: tokenObject as Prisma.InputJsonValue,
},
});
// If no delegation-credential is updated, create one
if (updated.count === 0) {
log.debug("No delegation-credential found. Creating one");
await CredentialRepository.createDelegationCredential({
userId,
delegationCredentialId: delegatedToId,
type: credentialType,
key: tokenObject as Prisma.InputJsonValue,
appId,
});
}
} else {
const { credentialId } = args;
await CredentialRepository.updateWhereId({
id: credentialId,
data: {
key: tokenObject as Prisma.InputJsonValue,
},
});
}
};
+1 -1
View File
@@ -9,7 +9,7 @@
"categories": ["payment"],
"publisher": "Alby",
"email": "support@getalby.com",
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r",
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your [Cal.com](https://cal.com) meetings.\r",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
+1 -1
View File
@@ -9,7 +9,7 @@
"categories": ["automation"],
"publisher": "Patrick Goeler von Ravensburg",
"email": "patrickvongoeler@gmail.com",
"description": "You simply paste a Cal.com link of someone, select a frequency and we will take care of the scheduling for you.\r\r",
"description": "You simply paste a [Cal.com](https://cal.com) link of someone, select a frequency and we will take care of the scheduling for you.\r\r",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "link-as-an-app"
+1 -1
View File
@@ -17,7 +17,7 @@ Create agents using 0 Code! Edit our pre-built agents or design your own agent f
Our agents understand your business and speak with the tone and finesse of trained receptionists (understanding interruptions, pauses etc.)
### Function calling
Simply add functions to book calendar appointments(cal.com), transfer calls to human agents in real time, or even create your own function calls
Simply add functions to book calendar appointments([cal.com](https://cal.com)), transfer calls to human agents in real time, or even create your own function calls
### Multi-lingual support
Create agents in any language and place calls anywhere around the world
+1 -1
View File
@@ -9,7 +9,7 @@
"categories": ["other"],
"publisher": "Chatbase",
"email": "sandra@chatbase.co",
"description": "Add Cal.com to your Chatbase AI Chatbot and accept bookings right inside chat.",
"description": "Add [Cal.com](https://cal.com/) to your Chatbase AI Chatbot and accept bookings right inside chat.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "link-as-an-app"
@@ -146,10 +146,13 @@ export const updateMeetingTokenIfExpired = async ({
return meetingToken;
};
export const generateGuestMeetingTokenFromOwnerMeetingToken = async (
meetingToken: string | null,
userId?: number
) => {
export const generateGuestMeetingTokenFromOwnerMeetingToken = async ({
meetingToken,
userId,
}: {
meetingToken: string | null;
userId?: number;
}) => {
if (!meetingToken) return null;
const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse);
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Dub",
"slug": "dub",
"type": "dub_analytics",
"logo": "icon.svg",
"logo": "icon-dark.svg",
"url": "https://dub.co",
"variant": "analytics",
"categories": ["analytics"],
+1 -1
View File
@@ -1 +1 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#fff"/></svg>
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

-1
View File
@@ -1 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1,282 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { calendar_v3 } from "@googleapis/calendar";
import type { Prisma } from "@prisma/client";
import { OAuth2Client, JWT } from "googleapis-common";
import {
CalendarAppDelegationCredentialClientIdNotAuthorizedError,
CalendarAppDelegationCredentialInvalidGrantError,
CalendarAppDelegationCredentialError,
} from "@calcom/lib/CalendarAppError";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import { invalidateCredential } from "../../_utils/invalidateCredential";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema";
import { metadata } from "../_metadata";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
type DelegatedTo = NonNullable<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarAuth"] });
class MyGoogleOAuth2Client extends OAuth2Client {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri);
}
isTokenExpiring() {
return super.isTokenExpiring();
}
async refreshToken(token: string | null | undefined) {
return super.refreshToken(token);
}
}
export class CalendarAuth {
private credential: CredentialForCalendarServiceWithEmail;
private jwtAuthClient: JWT | null = null;
private oAuthClient: MyGoogleOAuth2Client | null = null;
public authManager!: OAuthManager;
private authMechanism: ReturnType<CalendarAuth["initAuthMechanism"]>;
constructor(credential: CredentialForCalendarServiceWithEmail) {
this.credential = credential;
this.authMechanism = this.initAuthMechanism(credential);
}
private getAuthStrategy(): "jwt" | "oauth" {
return this.credential.delegatedToId ? "jwt" : "oauth";
}
private async getOAuthClientSingleton() {
if (this.oAuthClient) {
log.debug("Reusing existing oAuthClient");
return this.oAuthClient;
}
log.debug("Creating new oAuthClient");
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key);
this.oAuthClient = new MyGoogleOAuth2Client(client_id, client_secret, redirect_uris[0]);
this.oAuthClient.setCredentials(googleCredentials);
return this.oAuthClient;
}
private async getJwtClientSingleton({
emailToImpersonate,
delegatedTo,
}: {
emailToImpersonate: string | null;
delegatedTo: DelegatedTo;
}) {
if (!emailToImpersonate) {
log.error("DelegatedTo: No email to impersonate found for delegation credential");
return null;
}
const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/;
if (!this.jwtAuthClient) {
log.debug("Creating new delegation credential authClient");
const authClient = new JWT({
email: delegatedTo.serviceAccountKey.client_email,
key: delegatedTo.serviceAccountKey.private_key,
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: emailToImpersonate.replace(oauthClientIdAliasRegex, ""),
});
this.jwtAuthClient = authClient;
} else {
log.debug("Reusing existing delegation credential authClient");
}
return this.jwtAuthClient;
}
private async refreshOAuthToken({ refreshToken }: { refreshToken: string | null }) {
const oAuthClient = await this.getOAuthClientSingleton();
return oAuthClient.refreshToken(refreshToken);
}
private refreshJwtToken = async ({ delegatedTo }: { delegatedTo: DelegatedTo }) => {
log.debug("Attempting to authorize using JWT auth");
const { user } = this.credential;
const emailToImpersonate = user?.email ?? null;
const authClient = await this.getJwtClientSingleton({ delegatedTo, emailToImpersonate });
if (!authClient) {
log.error("JWT auth: No auth client found");
return null;
}
try {
log.debug("Authorizing using JWT auth");
return await authClient.authorize();
} catch (error) {
log.error("DelegatedTo: Error authorizing using JWT auth", JSON.stringify(error));
if ((error as any).response?.data?.error === "unauthorized_client") {
throw new CalendarAppDelegationCredentialClientIdNotAuthorizedError(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
}
if ((error as any).response?.data?.error === "invalid_grant") {
throw new CalendarAppDelegationCredentialInvalidGrantError(
`User ${emailToImpersonate} might not exist in Google Workspace`
);
}
// Catch all error
throw new CalendarAppDelegationCredentialError("Error authorizing delegation credential");
}
};
private initAuthMechanism(credential: CredentialForCalendarServiceWithEmail) {
const authStrategy = this.getAuthStrategy();
const authManager = new OAuthManager({
// Keep it false for oauth because Google's OAuth2Client library that we use supports token expiry check, itself when we use the client to make any request
// We keep it true for jwt because JWT Client doesn't support token expiry check and we do it ourselves
autoCheckTokenExpiryOnRequest: authStrategy !== "oauth",
...(authStrategy === "oauth"
? {
// Use Google's OAuth2Client library itself to check if the token is expiring
// For JWT, OAuthManager will do it itself
isTokenExpiring: async () => {
const oAuthClient = await this.getOAuthClientSingleton();
return oAuthClient.isTokenExpiring();
},
}
: {}),
credentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
},
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: metadata.slug,
getCurrentTokenObject: async () => {
return oAuthManagerHelper.getCurrentTokenObject(this.credential);
},
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
let result;
if (authStrategy === "jwt" && this.credential.delegatedTo) {
log.debug("Fetching new token object for JWT auth");
result = {
// In case of JWT Token flow, there is no refresh token, so we need to refresh the token using Service Account
tokenObject: await this.refreshJwtToken({ delegatedTo: this.credential.delegatedTo }),
status: 200,
statusText: "OK",
};
}
if (!result || !result.tokenObject) {
log.debug("Fetching new token object for my Google Auth");
const tokenFetchedResult = await this.refreshOAuthToken({ refreshToken });
result = {
tokenObject: tokenFetchedResult.res?.data ?? null,
status: tokenFetchedResult.res?.status,
statusText: tokenFetchedResult.res?.statusText,
};
}
return new Response(JSON.stringify(result.tokenObject), {
status: result.status,
statusText: result.statusText,
});
},
isTokenObjectUnusable: async function (response) {
// TODO: Confirm that if this logic should go to isAccessTokenUnusable
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
if (responseBody.error === "invalid_grant") {
return {
reason: "invalid_grant",
};
}
}
return null;
},
isAccessTokenUnusable: async () => {
// As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected
// So, a situation where access_token is invalid but refresh_token is valid should not happen
return null;
},
invalidateTokenObject: () => invalidateCredential(this.credential.id),
expireAccessToken: async () => {
await oAuthManagerHelper.markTokenAsExpired(this.credential);
},
updateTokenObject: async (token) => {
await oAuthManagerHelper.updateTokenObjectInDb({
tokenObject: token,
authStrategy: this.getAuthStrategy(),
credentialId: this.credential.id,
userId: this.credential.userId ?? null,
delegatedToId: this.credential.delegatedToId ?? null,
credentialType: this.credential.type,
appId: metadata.slug,
});
if (this.oAuthClient) {
this.oAuthClient.setCredentials(token);
}
// Update cached credential as well
this.credential.key = token as Prisma.JsonValue;
},
});
this.authManager = authManager;
return {
getOAuthClientWithRefreshedToken: async () => {
const { token } = await authManager.getTokenObjectOrFetch();
if (!token) {
throw new Error("Invalid grant for Google Calendar app");
}
const oAuthClient = await this.getOAuthClientSingleton();
return oAuthClient;
},
getJwtClientWithRefreshedToken: async ({ delegatedTo }: { delegatedTo: DelegatedTo }) => {
log.debug("Getting JWT client with refreshed token");
await authManager.getTokenObjectOrFetch();
return this.getJwtClientSingleton({
emailToImpersonate: this.credential.user?.email ?? null,
delegatedTo,
});
},
};
}
/**
* Returns a Google Calendar client that is authenticated with the user's credentials.
* If the user is delegated, it will use the delegation credential.
* If the user is not delegated, it will use the user's OAuth credentials.
*/
public async getClient(): Promise<calendar_v3.Calendar> {
log.debug("Getting authed calendar client");
let googleAuthClient;
const authStrategy = this.getAuthStrategy();
if (authStrategy === "jwt" && this.credential.delegatedTo) {
googleAuthClient = await this.authMechanism.getJwtClientWithRefreshedToken({
delegatedTo: this.credential.delegatedTo,
});
}
if (!googleAuthClient) {
googleAuthClient = await this.authMechanism.getOAuthClientWithRefreshedToken();
}
if (!googleAuthClient) {
throw new Error("Failed to initialize Google Auth client");
}
return new calendar_v3.Calendar({
auth: googleAuthClient,
});
}
}
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { calendar_v3 } from "@googleapis/calendar";
import type { calendar_v3 } from "@googleapis/calendar";
import type { Prisma } from "@prisma/client";
import { OAuth2Client, JWT } from "googleapis-common";
import type { GaxiosResponse } from "googleapis-common";
import { RRule } from "rrule";
import { v4 as uuid } from "uuid";
@@ -12,18 +11,7 @@ import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import {
CalendarAppDelegationCredentialClientIdNotAuthorizedError,
CalendarAppDelegationCredentialInvalidGrantError,
CalendarAppDelegationCredentialError,
} from "@calcom/lib/CalendarAppError";
import { uniqueBy } from "@calcom/lib/array";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -39,14 +27,8 @@ import type {
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import { invalidateCredential } from "../../_utils/invalidateCredential";
import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema";
import { metadata } from "../_metadata";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
import { CalendarAuth } from "./CalendarAuth";
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] });
@@ -73,15 +55,14 @@ type GoogleChannelProps = {
export default class GoogleCalendarService implements Calendar {
private integrationName = "";
private auth: ReturnType<typeof this.initGoogleAuth>;
private auth: CalendarAuth;
private log: typeof logger;
private credential: CredentialForCalendarServiceWithEmail;
private myGoogleAuth!: MyGoogleAuth;
private oAuthManagerInstance!: OAuthManager;
constructor(credential: CredentialForCalendarServiceWithEmail) {
this.integrationName = "google_calendar";
this.credential = credential;
this.auth = this.initGoogleAuth(credential);
this.auth = new CalendarAuth(credential);
this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
@@ -89,186 +70,11 @@ export default class GoogleCalendarService implements Calendar {
return this.credential.id;
}
private async getMyGoogleAuthSingleton() {
if (this.myGoogleAuth) {
return this.myGoogleAuth;
}
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key);
this.myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
this.myGoogleAuth.setCredentials(googleCredentials);
return this.myGoogleAuth;
public async authedCalendar(): Promise<calendar_v3.Calendar> {
this.log.debug("Getting authed calendar");
return this.auth.getClient();
}
private initGoogleAuth = (credential: CredentialForCalendarServiceWithEmail) => {
const currentTokenObject = getTokenObjectFromCredential(credential);
const auth = new OAuthManager({
// Keep it false because we are not using auth.request everywhere. That would be done later as it involves many google calendar sdk functionc calls and needs to be tested well.
autoCheckTokenExpiryOnRequest: false,
credentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
},
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: metadata.slug,
currentTokenObject,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
const fetchTokens = await myGoogleAuth.refreshToken(refreshToken);
// Create Response from fetchToken.res
const response = new Response(JSON.stringify(fetchTokens.res?.data ?? null), {
status: fetchTokens.res?.status,
statusText: fetchTokens.res?.statusText,
});
return response;
},
isTokenExpired: async () => {
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
return myGoogleAuth.isTokenExpiring();
},
isTokenObjectUnusable: async function (response) {
// TODO: Confirm that if this logic should go to isAccessTokenUnusable
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
if (responseBody.error === "invalid_grant") {
return {
reason: "invalid_grant",
};
}
}
return null;
},
isAccessTokenUnusable: async () => {
// As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected
// So, a situation where access_token is invalid but refresh_token is valid should not happen
return null;
},
invalidateTokenObject: () => invalidateCredential(this.credential.id),
expireAccessToken: async () => {
await markTokenAsExpired(this.credential);
},
updateTokenObject: async (token) => {
this.myGoogleAuth.setCredentials(token);
const { key } = await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: token,
},
});
// Update cached credential as well
this.credential.key = key;
},
});
this.oAuthManagerInstance = auth;
return {
getMyGoogleAuthWithRefreshedToken: async () => {
// It would automatically update myGoogleAuth with correct token
const { token } = await auth.getTokenObjectOrFetch();
if (!token) {
throw new Error("Invalid grant for Google Calendar app");
}
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
return myGoogleAuth;
},
};
};
private getAuthedCalendarFromDelegationCredential = async ({
delegationCredential,
emailToImpersonate,
}: {
emailToImpersonate: string;
delegationCredential: {
serviceAccountKey: {
client_email: string;
client_id: string;
private_key: string;
};
};
}) => {
const serviceAccountClientEmail = delegationCredential.serviceAccountKey.client_email;
const serviceAccountClientId = delegationCredential.serviceAccountKey.client_id;
const serviceAccountPrivateKey = delegationCredential.serviceAccountKey.private_key;
const authClient = new JWT({
email: serviceAccountClientEmail,
key: serviceAccountPrivateKey,
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: emailToImpersonate,
});
try {
await authClient.authorize();
} catch (error) {
this.log.error("DelegationCredential: Error authorizing delegation credential", JSON.stringify(error));
if ((error as any).response?.data?.error === "unauthorized_client") {
throw new CalendarAppDelegationCredentialClientIdNotAuthorizedError(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
}
if ((error as any).response?.data?.error === "invalid_grant") {
throw new CalendarAppDelegationCredentialInvalidGrantError(
`User ${emailToImpersonate} might not exist in Google Workspace`
);
}
// Catch all error
throw new CalendarAppDelegationCredentialError("Error authorizing delegation credential");
}
this.log.debug(
"Using delegation credential with service account email",
safeStringify({
serviceAccountClientEmail,
serviceAccountClientId,
emailToImpersonate,
})
);
return new calendar_v3.Calendar({
auth: authClient,
});
};
public authedCalendar = async () => {
let delegationCredentialAuthedCalendar;
if (this.credential.delegatedTo) {
if (!this.credential.user?.email) {
this.log.error("DelegationCredential: No email to impersonate found for delegation credential");
} else {
const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/;
delegationCredentialAuthedCalendar = await this.getAuthedCalendarFromDelegationCredential({
delegationCredential: this.credential.delegatedTo,
emailToImpersonate: this.credential.user.email.replace(oauthClientIdAliasRegex, ""),
});
}
}
if (delegationCredentialAuthedCalendar) {
return delegationCredentialAuthedCalendar;
}
const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken();
const calendar = new calendar_v3.Calendar({
auth: myGoogleAuth,
});
return calendar;
};
private getAttendees = ({
event,
hostExternalCalendarId,
@@ -656,7 +462,7 @@ export default class GoogleCalendarService implements Calendar {
async fetchAvailability(requestBody: FreeBusyArgs): Promise<calendar_v3.Schema$FreeBusyResponse> {
log.debug("fetchAvailability", safeStringify({ requestBody }));
const calendar = await this.authedCalendar();
const apiResponse = await this.oAuthManagerInstance.request(
const apiResponse = await this.auth.authManager.request(
async () => new AxiosLikeResponseToFetchResponse(await calendar.freebusy.query({ requestBody }))
);
return apiResponse.json;
@@ -896,7 +702,7 @@ export default class GoogleCalendarService implements Calendar {
this.log.debug("Listing calendars");
const calendar = await this.authedCalendar();
try {
const { json: cals } = await this.oAuthManagerInstance.request(
const { json: cals } = await this.auth.authManager.request(
async () =>
new AxiosLikeResponseToFetchResponse({
status: 200,
@@ -927,6 +733,7 @@ export default class GoogleCalendarService implements Calendar {
// It would error if the delegation credential is not set up correctly
async testDelegationCredentialSetup() {
log.debug("Testing delegation credential setup");
const calendar = await this.authedCalendar();
const cals = await calendar.calendarList.list({ fields: "items(id)" });
return !!cals.data.items;
@@ -974,15 +781,13 @@ export default class GoogleCalendarService implements Calendar {
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
}
: {};
let error: string | null = null;
if (!otherCalendarsWithSameSubscription.length) {
try {
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
} catch (_error) {
this.log.error(`Failed to watch calendar ${calendarId}`, _error);
// We set error to prevent attempting to watch on next cron run
error = _error instanceof Error ? _error.message : "Unknown error";
} catch (error) {
this.log.error(`Failed to watch calendar ${calendarId}`, safeStringify(error));
throw error;
}
} else {
logger.info(
@@ -1000,7 +805,6 @@ export default class GoogleCalendarService implements Calendar {
googleChannelResourceId: googleChannelProps.resourceId,
googleChannelResourceUri: googleChannelProps.resourceUri,
googleChannelExpiration: googleChannelProps.expiration,
error,
},
eventTypeIds
);
@@ -1253,17 +1057,3 @@ export default class GoogleCalendarService implements Calendar {
}
}
}
class MyGoogleAuth extends OAuth2Client {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri);
}
isTokenExpiring() {
return super.isTokenExpiring();
}
async refreshToken(token: string | null | undefined) {
return super.refreshToken(token);
}
}
@@ -0,0 +1,9 @@
import { vi } from "vitest";
const featuresRepositoryModuleMock = {
FeaturesRepository: vi.fn().mockImplementation(() => ({
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
})),
};
vi.mock("@calcom/features/flags/features.repository", () => featuresRepositoryModuleMock);
@@ -0,0 +1,11 @@
import { vi } from "vitest";
export const getGoogleAppKeysModuleMock = {
getGoogleAppKeys: vi.fn().mockResolvedValue({
client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
client_secret: "xxxxxxxxxxxxxxxxxx",
redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"],
}),
};
vi.mock("../getGoogleAppKeys", () => getGoogleAppKeysModuleMock);
@@ -36,5 +36,92 @@ const adminMock = {
Admin: vi.fn(),
},
};
export interface MockJWT {
type: "jwt";
config: {
email: string;
key: string;
scopes: string[];
subject: string;
};
authorize: () => Promise<void>;
}
export { calendarMock, adminMock, setCredentialsMock, freebusyQueryMock, calendarListMock };
export type MockOAuth2Client = {
type: "oauth2";
args: [string, string, string];
setCredentials: typeof setCredentialsMock;
refreshToken: Function;
isTokenExpiring: Function;
};
export const MOCK_JWT_TOKEN = {
access_token: "MOCK_ACCESS_TOKEN_JWT",
refresh_token: "placeholder_refresh_token",
scope: "https://www.googleapis.com/auth/calendar",
token_type: "Bearer",
expiry_date: 1625097600000,
};
export const MOCK_OAUTH2_TOKEN = {
access_token: "MOCK_ACCESS_TOKEN_OAUTH2",
refresh_token: "MOCK_REFRESH_TOKEN_OAUTH2",
scope: "https://www.googleapis.com/auth/calendar",
token_type: "Bearer",
expiry_date: 1625097600000,
};
let lastCreatedJWT: MockJWT | null = null;
let lastCreatedOAuth2Client: MockOAuth2Client | null = null;
vi.mock("googleapis-common", async () => {
const actual = await vi.importActual("googleapis-common");
return {
...actual,
OAuth2Client: vi.fn().mockImplementation((...args: [string, string, string]) => {
lastCreatedOAuth2Client = {
type: "oauth2",
args,
setCredentials: setCredentialsMock,
isTokenExpiring: vi.fn().mockReturnValue(true),
refreshToken: vi.fn().mockResolvedValue({
res: {
data: MOCK_OAUTH2_TOKEN,
status: 200,
statusText: "OK",
},
}),
};
return lastCreatedOAuth2Client;
}),
JWT: vi.fn().mockImplementation((config: MockJWT["config"]) => {
lastCreatedJWT = {
type: "jwt",
config,
authorize: vi.fn().mockResolvedValue(undefined),
};
return lastCreatedJWT;
}),
};
});
vi.mock("@googleapis/admin", () => adminMock);
vi.mock("@googleapis/calendar", () => calendarMock);
const getLastCreatedJWT = () => lastCreatedJWT;
const getLastCreatedOAuth2Client = () => lastCreatedOAuth2Client;
const setLastCreatedJWT = (jwt: MockJWT | null) => {
lastCreatedJWT = jwt;
};
const setLastCreatedOAuth2Client = (oauth2Client: MockOAuth2Client | null) => {
lastCreatedOAuth2Client = oauth2Client;
};
export {
calendarMock,
adminMock,
setCredentialsMock,
freebusyQueryMock,
calendarListMock,
getLastCreatedJWT,
getLastCreatedOAuth2Client,
setLastCreatedJWT,
setLastCreatedOAuth2Client,
};
@@ -0,0 +1,312 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import "../__mocks__/features.repository";
import "../__mocks__/getGoogleAppKeys";
import {
setCredentialsMock,
calendarListMock,
getLastCreatedJWT,
getLastCreatedOAuth2Client,
setLastCreatedJWT,
setLastCreatedOAuth2Client,
calendarMock,
adminMock,
MOCK_JWT_TOKEN,
MOCK_OAUTH2_TOKEN,
} from "../__mocks__/googleapis";
import { expect, test, beforeEach, vi, describe } from "vitest";
import "vitest-fetch-mock";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import CalendarService from "../CalendarService";
import {
createMockJWTInstance,
createInMemoryDelegationCredentialForCalendarService,
defaultDelegatedCredential,
createCredentialForCalendarService,
} from "./utils";
function expectJWTInstanceToBeCreated() {
expect(getLastCreatedJWT()).toBeDefined();
expect(setCredentialsMock).not.toHaveBeenCalled();
}
function expectOAuth2InstanceToBeCreated() {
expect(setCredentialsMock).toHaveBeenCalled();
expect(getLastCreatedJWT()).toBeNull();
}
function mockSuccessfulCalendarListFetch() {
calendarListMock.mockImplementation(() => {
return {
data: { items: [] },
};
});
}
beforeEach(() => {
vi.clearAllMocks();
setCredentialsMock.mockClear();
calendarMock.calendar_v3.Calendar.mockClear();
adminMock.admin_directory_v1.Admin.mockClear();
setLastCreatedJWT(null);
setLastCreatedOAuth2Client(null);
createMockJWTInstance({});
});
async function expectNoCredentialsInDb() {
const credentials = await prismock.credential.findMany({});
expect(credentials).toHaveLength(0);
}
async function expectCredentialsInDb(credentials: CredentialForCalendarServiceWithEmail[]) {
const credentialsInDb = await prismock.credential.findMany({});
expect(credentialsInDb.length).toBe(credentials.length);
expect(credentialsInDb).toEqual(expect.arrayContaining(credentials));
}
describe("GoogleCalendarService credential handling", () => {
describe("Delegation Credential", () => {
test("uses JWT auth with impersonation when listCalendars is called and creates a new credential in DB with the key as JWT token if it doesn't exist", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
mockSuccessfulCalendarListFetch();
expectNoCredentialsInDb();
const calendarService = new CalendarService(credentialWithDelegation);
await calendarService.listCalendars();
expectJWTInstanceToBeCreated();
await expectCredentialsInDb([
expect.objectContaining({
delegationCredentialId: credentialWithDelegation.delegationCredentialId,
key: MOCK_JWT_TOKEN,
userId: credentialWithDelegation.userId,
}),
]);
});
test("uses JWT auth with impersonation when listCalendars is called and updates the credential in DB with the new JWT token if it exists", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
mockSuccessfulCalendarListFetch();
const existingCredential = await prismock.credential.create({
data: {
type: "google_calendar",
delegationCredentialId: credentialWithDelegation.delegationCredentialId,
userId: credentialWithDelegation.userId,
key: {
access_token: "CURRENT_ACCESS_TOKEN",
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await calendarService.listCalendars();
expectJWTInstanceToBeCreated();
await expectCredentialsInDb([
expect.objectContaining({
id: existingCredential.id,
delegationCredentialId: credentialWithDelegation.delegationCredentialId,
key: MOCK_JWT_TOKEN,
userId: credentialWithDelegation.userId,
}),
]);
});
test("JWT token is reused when not expired when listCalendars is called again on a new instance of CalendarService", async () => {
const jwtTokenThatHasNotExpired = {
...MOCK_JWT_TOKEN,
expiry_date: Date.now() + 1000 * 60 * 60 * 24,
};
createMockJWTInstance({
tokenExpiryDate: jwtTokenThatHasNotExpired.expiry_date,
});
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
console.log("TESTS: credentialWithDelegation", credentialWithDelegation);
mockSuccessfulCalendarListFetch();
await expectNoCredentialsInDb();
console.log("TESTS: First instance of CalendarService");
const calendarService1 = new CalendarService({
...credentialWithDelegation,
});
await calendarService1.listCalendars();
await expectCredentialsInDb([
expect.objectContaining({
delegationCredentialId: credentialWithDelegation.delegationCredentialId,
key: jwtTokenThatHasNotExpired,
userId: credentialWithDelegation.userId,
}),
]);
const existingCredential = await prismock.credential.findFirst({
where: {
delegationCredentialId: credentialWithDelegation.delegationCredentialId,
userId: credentialWithDelegation.userId,
},
});
expect(existingCredential).toBeDefined();
console.log("TESTS: Second instance of CalendarService");
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "I_WOULD_ERROR_IF_YOU_USE_ME",
},
},
},
});
const calendarService2 = new CalendarService({
...credentialWithDelegation,
});
await calendarService2.listCalendars();
await expectCredentialsInDb([
expect.objectContaining({
// Same credential should be reused
id: existingCredential?.id,
delegationCredentialId: credentialWithDelegation.delegationCredentialId,
key: jwtTokenThatHasNotExpired,
}),
]);
});
});
describe("Non-Delegation Credential", () => {
test("uses OAuth2 auth when listCalendars is called", async () => {
const regularCredential = await createCredentialForCalendarService();
mockSuccessfulCalendarListFetch();
const calendarService = new CalendarService(regularCredential);
await calendarService.listCalendars();
expectOAuth2InstanceToBeCreated();
expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({
auth: getLastCreatedOAuth2Client(),
});
await expectCredentialsInDb([
expect.objectContaining({
id: regularCredential.id,
key: MOCK_OAUTH2_TOKEN,
}),
]);
});
});
describe("Delegation Credential Error handling", () => {
test("handles clientId not added to Google Workspace Admin Console error", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "unauthorized_client",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
});
test("handles DelegationCredential authorization errors appropriately", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "unauthorized_client",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
});
test("handles invalid_grant error (user not in workspace) appropriately", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "invalid_grant",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
`User ${credentialWithDelegation.user?.email} might not exist in Google Workspace`
);
});
test("handles DelegationCredential authorization errors appropriately", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
createMockJWTInstance({
authorizeError: new Error("Some unexpected error"),
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
"Error authorizing delegation credential"
);
});
test("On missing user email for DelegationCredential, it should fallback to OAuth2 auth", async () => {
const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({
user: { email: null },
delegatedTo: defaultDelegatedCredential,
delegationCredentialId: "delegation-credential-id-1",
});
const calendarService = new CalendarService(credentialWithDelegation);
mockSuccessfulCalendarListFetch();
await calendarService.listCalendars();
expectOAuth2InstanceToBeCreated();
});
});
});
@@ -1,17 +1,20 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import oAuthManagerMock, {
defaultMockOAuthManager,
setFullMockOAuthManagerRequest,
} from "../../tests/__mocks__/OAuthManager";
} from "../../../tests/__mocks__/OAuthManager";
import "../__mocks__/features.repository";
import "../__mocks__/getGoogleAppKeys";
import {
adminMock,
calendarMock,
adminMock,
setLastCreatedJWT,
setCredentialsMock,
setLastCreatedOAuth2Client,
freebusyQueryMock,
calendarListMock,
} from "./__mocks__/googleapis";
} from "../__mocks__/googleapis";
import { JWT } from "googleapis-common";
import { expect, test, beforeEach, vi, describe } from "vitest";
import "vitest-fetch-mock";
@@ -20,82 +23,17 @@ import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/date
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import CalendarService from "./CalendarService";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
import CalendarService from "../CalendarService";
import {
createMockJWTInstance,
createInMemoryDelegationCredentialForCalendarService,
defaultDelegatedCredential,
createCredentialForCalendarService,
createInMemoryCredential,
} from "./utils";
const log = logger.getSubLogger({ prefix: ["CalendarService.test"] });
vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token");
interface MockJWT {
type: "jwt";
config: {
email: string;
key: string;
scopes: string[];
subject: string;
};
authorize: () => Promise<void>;
}
interface MockOAuth2Client {
type: "oauth2";
args: [string, string, string];
setCredentials: typeof setCredentialsMock;
}
let lastCreatedJWT: MockJWT | null = null;
let lastCreatedOAuth2Client: MockOAuth2Client | null = null;
vi.mock("@calcom/features/flags/features.repository", () => ({
FeaturesRepository: vi.fn().mockImplementation(() => ({
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
})),
}));
vi.mock("./getGoogleAppKeys", () => ({
getGoogleAppKeys: vi.fn().mockResolvedValue({
client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
client_secret: "xxxxxxxxxxxxxxxxxx",
redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"],
}),
}));
vi.mock("googleapis-common", async () => {
const actual = await vi.importActual("googleapis-common");
return {
...actual,
OAuth2Client: vi.fn().mockImplementation((...args: [string, string, string]) => {
lastCreatedOAuth2Client = {
type: "oauth2",
args,
setCredentials: setCredentialsMock,
};
return lastCreatedOAuth2Client;
}),
JWT: vi.fn().mockImplementation((config: MockJWT["config"]) => {
lastCreatedJWT = {
type: "jwt",
config,
authorize: vi.fn().mockResolvedValue(undefined),
};
return lastCreatedJWT;
}),
};
});
vi.mock("@googleapis/admin", () => adminMock);
vi.mock("@googleapis/calendar", () => calendarMock);
async function expectCacheToBeNotSet({ credentialId }: { credentialId: number }) {
const caches = await prismock.calendarCache.findMany({
where: {
credentialId,
},
});
expect(caches).toHaveLength(0);
}
async function expectCacheToBeSet({
credentialId,
@@ -117,98 +55,6 @@ async function expectCacheToBeSet({
);
}
function createInMemoryCredential({
userId,
delegationCredentialId,
delegatedTo,
}: {
userId: number;
delegationCredentialId: string | null;
delegatedTo: NonNullable<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
}) {
return {
id: -1,
userId,
key: {
access_token: "NOOP_UNUSED_DELEGATION_TOKEN",
},
invalid: false,
teamId: null,
team: null,
type: "google_calendar",
appId: "google-calendar",
delegatedToId: delegationCredentialId,
delegatedTo: delegatedTo.serviceAccountKey
? {
serviceAccountKey: delegatedTo.serviceAccountKey,
}
: null,
};
}
async function createCredentialForCalendarService({
user = undefined,
delegatedTo = null,
delegationCredentialId = null,
}: {
user?: { email: string | null };
delegatedTo?: NonNullable<CredentialForCalendarServiceWithEmail["delegatedTo"]> | null;
delegationCredentialId?: string | null;
} = {}): Promise<CredentialForCalendarServiceWithEmail> {
const defaultUser = await prismock.user.create({
data: {
email: user?.email ?? "",
},
});
const app = await prismock.app.create({
data: {
slug: "google-calendar",
dirName: "google-calendar",
},
});
const credential = {
...getSampleCredential(),
...(delegationCredentialId ? { delegationCredential: { connect: { id: delegationCredentialId } } } : {}),
key: {
...googleTestCredentialKey,
expiry_date: Date.now() - 1000,
},
};
const credentialInDb = !delegatedTo
? await prismock.credential.create({
data: {
...credential,
user: {
connect: {
id: defaultUser.id,
},
},
app: {
connect: {
slug: app.slug,
},
},
},
include: {
user: true,
},
})
: createInMemoryCredential({
userId: defaultUser.id,
delegationCredentialId,
delegatedTo,
});
return {
...credentialInDb,
user: user ? { email: user.email ?? "" } : null,
delegatedTo,
} as CredentialForCalendarServiceWithEmail;
}
async function createSelectedCalendarForDelegationCredential(data: {
userId: number;
credentialId: number | null;
@@ -231,61 +77,6 @@ async function createSelectedCalendarForDelegationCredential(data: {
});
}
async function createSelectedCalendarForRegularCredential(data: {
userId: number;
delegationCredentialId: null;
credentialId: number;
externalId: string;
integration: string;
googleChannelId: string | null;
googleChannelKind: string | null;
googleChannelResourceId: string | null;
googleChannelResourceUri: string | null;
googleChannelExpiration: string | null;
}) {
if (!data.credentialId) {
throw new Error("credentialId is required");
}
if (data.credentialId < 0) {
throw new Error("credentialId cannot be negative");
}
return await prismock.selectedCalendar.create({
data: {
...data,
delegationCredentialId: null,
credentialId: data.credentialId,
},
});
}
const defaultDelegatedCredential = {
serviceAccountKey: {
client_email: "service@example.com",
client_id: "service-client-id",
private_key: "service-private-key",
},
} as const;
async function createDelegationCredentialForCalendarService({
user,
delegatedTo,
delegationCredentialId,
}: {
user?: { email: string } | null;
delegatedTo?: typeof defaultDelegatedCredential;
delegationCredentialId: string;
}) {
return await createCredentialForCalendarService({
user: user || {
email: "service@example.com",
},
delegatedTo: delegatedTo || defaultDelegatedCredential,
delegationCredentialId,
});
}
/**
* The flow that sets CalendarCache must use CredentialForCalendarCache
*/
@@ -316,67 +107,6 @@ async function createDelegationCredentialForCalendarCache({
};
}
const createMockJWTInstance = ({
email = "user@example.com",
authorizeError,
}: {
email?: string;
authorizeError?: { response?: { data?: { error?: string } } } | Error;
}) => {
const mockJWTInstance = {
type: "jwt" as const,
config: {
email: defaultDelegatedCredential.serviceAccountKey.client_email,
key: defaultDelegatedCredential.serviceAccountKey.private_key,
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: email,
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
authorize: authorizeError ? vi.fn().mockRejectedValue(authorizeError) : vi.fn().mockResolvedValue(),
createScoped: vi.fn(),
getRequestMetadataAsync: vi.fn(),
fetchIdToken: vi.fn(),
hasUserScopes: vi.fn(),
getAccessToken: vi.fn(),
getRefreshToken: vi.fn(),
getTokenInfo: vi.fn(),
refreshAccessToken: vi.fn(),
revokeCredentials: vi.fn(),
revokeToken: vi.fn(),
verifyIdToken: vi.fn(),
on: vi.fn(),
setCredentials: vi.fn(),
getCredentials: vi.fn(),
hasAnyScopes: vi.fn(),
authorizeAsync: vi.fn(),
refreshTokenNoCache: vi.fn(),
createGToken: vi.fn(),
};
vi.mocked(JWT).mockImplementation(() => {
lastCreatedJWT = mockJWTInstance;
return mockJWTInstance as unknown as JWT;
});
return mockJWTInstance;
};
const googleTestCredentialKey = {
scope: "https://www.googleapis.com/auth/calendar.events",
token_type: "Bearer",
expiry_date: 1625097600000,
access_token: "",
refresh_token: "",
};
const getSampleCredential = () => {
return {
invalid: false,
key: googleTestCredentialKey,
type: "google_calendar",
};
};
const testSelectedCalendar = {
userId: 1,
integration: "google_calendar",
@@ -522,8 +252,8 @@ beforeEach(() => {
calendarMock.calendar_v3.Calendar.mockClear();
adminMock.admin_directory_v1.Admin.mockClear();
lastCreatedJWT = null;
lastCreatedOAuth2Client = null;
setLastCreatedJWT(null);
setLastCreatedOAuth2Client(null);
createMockJWTInstance({});
});
@@ -802,7 +532,7 @@ describe("Watching and unwatching calendar", () => {
describe("Delegation Credential", () => {
test("On watching a SelectedCalendar having delegationCredential, it should set googleChannelId and other props", async () => {
const delegationCredential1Member1 = await createDelegationCredentialForCalendarService({
const delegationCredential1Member1 = await createInMemoryDelegationCredentialForCalendarService({
user: { email: "user1@example.com" },
delegationCredentialId: "delegation-credential-id-1",
});
@@ -1086,298 +816,6 @@ describe("Watching and unwatching calendar", () => {
});
});
test("`updateTokenObject` should update credential in DB as well as myGoogleAuth", async () => {
const credentialInDb = await createCredentialForCalendarService();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let updateTokenObject: any;
oAuthManagerMock.OAuthManager = vi.fn().mockImplementation((arg) => {
updateTokenObject = arg.updateTokenObject;
return {
getTokenObjectOrFetch: vi.fn().mockImplementation(() => {
return {
token: {
access_token: "FAKE_ACCESS_TOKEN",
},
};
}),
request: vi.fn().mockResolvedValue({
json: [],
}),
};
});
const calendarService = new CalendarService(credentialInDb);
await calendarService.listCalendars();
const newTokenObject = {
access_token: "NEW_FAKE_ACCESS_TOKEN",
};
// Scenario: OAuthManager causes `updateTokenObject` to be called
await updateTokenObject(newTokenObject);
const newCredential = await prismock.credential.findFirst({
where: {
id: credentialInDb.id,
},
});
// Expect update in DB
expect(newCredential).toEqual(
expect.objectContaining({
key: newTokenObject,
})
);
// Expect update in myGoogleAuth credentials
expect(setCredentialsMock).toHaveBeenCalledWith(newTokenObject);
});
describe("Delegation Credential Error handling", () => {
test("handles clientId not added to Google Workspace Admin Console error", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "unauthorized_client",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
});
test("handles DelegationCredential authorization errors appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "unauthorized_client",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
});
test("handles invalid_grant error (user not in workspace) appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "invalid_grant",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
`User ${credentialWithDelegation.user?.email} might not exist in Google Workspace`
);
});
test("handles general DelegationCredential authorization errors appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: new Error("Some unexpected error"),
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow("Error authorizing delegation credential");
});
test("handles missing user email for DelegationCredential appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: null },
delegatedTo: defaultDelegatedCredential,
});
const calendarService = new CalendarService(credentialWithDelegation);
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
await calendarService.listCalendars();
expect(lastCreatedJWT).toBeNull();
const expectedOAuth2Client: MockOAuth2Client = {
type: "oauth2",
args: [client_id, client_secret, redirect_uris[0]],
setCredentials: setCredentialsMock,
};
expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client);
});
});
describe("GoogleCalendarService credential handling", () => {
test("uses JWT auth with impersonation when Delegation credential is provided", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
const calendarService = new CalendarService(credentialWithDelegation);
await calendarService.listCalendars();
const expectedJWTConfig: MockJWT = {
type: "jwt",
config: {
email: defaultDelegatedCredential.serviceAccountKey.client_email,
key: defaultDelegatedCredential.serviceAccountKey.private_key,
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: "user@example.com",
},
authorize: expect.any(Function) as () => Promise<void>,
};
expect(lastCreatedJWT).toEqual(expect.objectContaining(expectedJWTConfig));
expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({
auth: lastCreatedJWT,
});
});
test("uses OAuth2 auth when no Delegation credential is provided", async () => {
const regularCredential = await createCredentialForCalendarService();
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
const calendarService = new CalendarService(regularCredential);
await calendarService.listCalendars();
expect(lastCreatedJWT).toBeNull();
const expectedOAuth2Client: MockOAuth2Client = {
type: "oauth2",
args: [client_id, client_secret, redirect_uris[0]],
setCredentials: setCredentialsMock,
};
expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client);
expect(setCredentialsMock).toHaveBeenCalledWith(regularCredential.key);
expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({
auth: lastCreatedOAuth2Client,
});
});
test("handles DelegationCredential authorization errors appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "unauthorized_client",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
"Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console"
);
});
test("handles invalid_grant error (user not in workspace) appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: {
response: {
data: {
error: "invalid_grant",
},
},
},
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow(
`User ${credentialWithDelegation.user?.email} might not exist in Google Workspace`
);
});
test("handles general DelegationCredential authorization errors appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: "user@example.com" },
delegatedTo: defaultDelegatedCredential,
});
createMockJWTInstance({
authorizeError: new Error("Some unexpected error"),
});
const calendarService = new CalendarService(credentialWithDelegation);
await expect(calendarService.listCalendars()).rejects.toThrow("Error authorizing delegation credential");
});
test("handles missing user email for DelegationCredential appropriately", async () => {
const credentialWithDelegation = await createCredentialForCalendarService({
user: { email: null },
delegatedTo: defaultDelegatedCredential,
});
const calendarService = new CalendarService(credentialWithDelegation);
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
await calendarService.listCalendars();
expect(lastCreatedJWT).toBeNull();
const expectedOAuth2Client: MockOAuth2Client = {
type: "oauth2",
args: [client_id, client_secret, redirect_uris[0]],
setCredentials: setCredentialsMock,
};
expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client);
});
});
describe("getAvailability", () => {
test("returns availability for selected calendars", async () => {
const credential = await createCredentialForCalendarService();
@@ -0,0 +1,201 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import { MOCK_JWT_TOKEN, setLastCreatedJWT } from "../__mocks__/googleapis";
import { JWT } from "googleapis-common";
import { vi } from "vitest";
import "vitest-fetch-mock";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token");
export function createInMemoryCredential({
userId,
delegationCredentialId,
delegatedTo,
}: {
userId: number;
delegationCredentialId: string;
delegatedTo: NonNullable<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
}) {
if (delegatedTo && !delegationCredentialId) {
throw new Error("Test: createInMemoryCredential: delegationCredentialId is required");
}
return {
id: -1,
userId,
key: {
access_token: "NOOP_UNUSED_DELEGATION_TOKEN",
},
invalid: false,
teamId: null,
team: null,
type: "google_calendar",
appId: "google-calendar",
delegatedToId: delegationCredentialId,
delegatedTo: delegatedTo.serviceAccountKey
? {
serviceAccountKey: delegatedTo.serviceAccountKey,
}
: null,
};
}
export async function createCredentialForCalendarService({
user = undefined,
delegatedTo = null,
delegationCredentialId = null,
}: {
user?: { email: string | null };
delegatedTo?: NonNullable<CredentialForCalendarServiceWithEmail["delegatedTo"]> | null;
delegationCredentialId?: string | null;
} = {}): Promise<CredentialForCalendarServiceWithEmail> {
const defaultUser = await prismock.user.create({
data: {
email: user?.email ?? "",
},
});
const app = await prismock.app.create({
data: {
slug: "google-calendar",
dirName: "google-calendar",
},
});
const credential = {
...getSampleCredential(),
...(delegationCredentialId ? { delegationCredential: { connect: { id: delegationCredentialId } } } : {}),
key: {
...googleTestCredentialKey,
expiry_date: Date.now() - 1000,
},
};
const credentialInDbOrInMemory = !delegatedTo
? await prismock.credential.create({
data: {
...credential,
user: {
connect: {
id: defaultUser.id,
},
},
app: {
connect: {
slug: app.slug,
},
},
},
include: {
user: true,
},
})
: createInMemoryCredential({
userId: defaultUser.id,
delegationCredentialId: delegationCredentialId!,
delegatedTo,
});
return {
...credentialInDbOrInMemory,
delegationCredentialId: delegationCredentialId ?? null,
user: user ? { email: user.email ?? "" } : null,
delegatedTo,
} as CredentialForCalendarServiceWithEmail;
}
export const defaultDelegatedCredential = {
serviceAccountKey: {
client_email: "service@example.com",
client_id: "service-client-id",
private_key: "service-private-key",
},
} as const;
export async function createInMemoryDelegationCredentialForCalendarService({
user,
delegatedTo,
delegationCredentialId,
}: {
user?: { email: string | null } | null;
delegatedTo?: typeof defaultDelegatedCredential;
delegationCredentialId: string;
}) {
return await createCredentialForCalendarService({
user: user || {
email: "service@example.com",
},
delegatedTo: delegatedTo || defaultDelegatedCredential,
delegationCredentialId,
});
}
export const createMockJWTInstance = ({
email = "user@example.com",
authorizeError,
tokenExpiryDate,
}: {
email?: string;
authorizeError?: { response?: { data?: { error?: string } } } | Error;
tokenExpiryDate?: number;
}) => {
console.log("createMockJWTInstance", { email, authorizeError });
const mockJWTInstance = {
type: "jwt" as const,
config: {
email: defaultDelegatedCredential.serviceAccountKey.client_email,
key: defaultDelegatedCredential.serviceAccountKey.private_key,
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: email,
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
authorize: authorizeError
? vi.fn().mockRejectedValue(authorizeError)
: vi.fn().mockResolvedValue({
...MOCK_JWT_TOKEN,
expiry_date: tokenExpiryDate ?? MOCK_JWT_TOKEN.expiry_date,
}),
createScoped: vi.fn(),
getRequestMetadataAsync: vi.fn(),
fetchIdToken: vi.fn(),
hasUserScopes: vi.fn(),
getAccessToken: vi.fn(),
getRefreshToken: vi.fn(),
getTokenInfo: vi.fn(),
refreshAccessToken: vi.fn(),
revokeCredentials: vi.fn(),
revokeToken: vi.fn(),
verifyIdToken: vi.fn(),
on: vi.fn(),
setCredentials: vi.fn(),
getCredentials: vi.fn(),
hasAnyScopes: vi.fn(),
authorizeAsync: vi.fn(),
refreshTokenNoCache: vi.fn(),
createGToken: vi.fn(),
};
vi.mocked(JWT).mockImplementation(() => {
setLastCreatedJWT(mockJWTInstance);
return mockJWTInstance as unknown as JWT;
});
return mockJWTInstance;
};
const googleTestCredentialKey = {
scope: "https://www.googleapis.com/auth/calendar.events",
token_type: "Bearer",
expiry_date: 1625097600000,
access_token: "",
refresh_token: "",
};
const getSampleCredential = () => {
return {
invalid: false,
key: googleTestCredentialKey,
type: "google_calendar",
};
};
+2 -2
View File
@@ -3,13 +3,13 @@
"name": "Greetmate.ai",
"slug": "greetmate-ai",
"type": "greetmate-ai_automation",
"logo": "icon.svg",
"logo": "icon-dark.svg",
"url": "https://www.greetmate.ai",
"variant": "automation",
"categories": ["automation"],
"publisher": "Greetmate Inc.",
"email": "support@greetmate.ai",
"description": "Connect Cal.com to Greetmate.ai and enable appointment scheduling for your AI agents.",
"description": "Connect [Cal.com](https://cal.com/) to [Greetmate.ai](https://www.greetmate.ai/) and enable appointment scheduling for your AI agents.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "link-as-an-app"

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+1 -1
View File
@@ -9,7 +9,7 @@
"categories": ["payment"],
"publisher": "HitPay",
"email": "support@hit-pay.com",
"description": "HitPay's Cal.com payment integration combines powerful scheduling with seamless local payment acceptance. Our online scheduling software integration is built for customers in APAC, supporting their favorite e-wallets and banking apps.<p>Accept a wide range of local payments including:</p><span><strong data-stringify-type=\"bold\">Real-time QR Code Payments:&nbsp;</strong>e.g. PayNow, DuitNow, QRIS, PromptPay</span><br/><span><strong data-stringify-type=\"bold\">E-Wallets:&nbsp;</strong>e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay</span><br/><span><strong data-stringify-type=\"bold\">Credit &amp; Debit Cards:&nbsp;</strong>e.g. Visa, Mastercard, American Express, UnionPay</span><br/><span><strong data-stringify-type=\"bold\">Buy Now, Pay Later:&nbsp;</strong>e.g. PayLater by Grab, SPayLater, Atome</span><br/><p>About HitPay</p><span>HitPay is a full-stack payments infrastructure platform designed for growing businesses in APAC. We unify e-commerce, point of sale, and B2B payments into a single platform. HitPay is regulated in 6 APAC jurisdictions and backed by leading global investors, including Tiger Global, Y Combinator, Global Founders Capital, and HOF Capital.</span><br/><p>Learn more at&nbsp;<a class=\"c-link\" href=\"https://hitpayapp.com/cal.com\" target=\"_blank\" rel=\"noopener noreferrer\" data-stringify-link=\"https://hitpayapp.com/cal.com\" data-sk=\"tooltip_parent\">https://hitpayapp.com/cal.com</a></p>",
"description": "HitPay's [Cal.com](https://cal.com) payment integration combines powerful scheduling with seamless local payment acceptance. Our online scheduling software integration is built for customers in APAC, supporting their favorite e-wallets and banking apps.<p>Accept a wide range of local payments including:</p><span><strong data-stringify-type=\"bold\">Real-time QR Code Payments:&nbsp;</strong>e.g. PayNow, DuitNow, QRIS, PromptPay</span><br/><span><strong data-stringify-type=\"bold\">E-Wallets:&nbsp;</strong>e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay</span><br/><span><strong data-stringify-type=\"bold\">Credit &amp; Debit Cards:&nbsp;</strong>e.g. Visa, Mastercard, American Express, UnionPay</span><br/><span><strong data-stringify-type=\"bold\">Buy Now, Pay Later:&nbsp;</strong>e.g. PayLater by Grab, SPayLater, Atome</span><br/><p>About HitPay</p><span>HitPay is a full-stack payments infrastructure platform designed for growing businesses in APAC. We unify e-commerce, point of sale, and B2B payments into a single platform. HitPay is regulated in 6 APAC jurisdictions and backed by leading global investors, including Tiger Global, Y Combinator, Global Founders Capital, and HOF Capital.</span><br/><p>Learn more at&nbsp;<a class=\"c-link\" href=\"https://hitpayapp.com/cal.com\" target=\"_blank\" rel=\"noopener noreferrer\" data-stringify-link=\"https://hitpayapp.com/cal.com\" data-sk=\"tooltip_parent\">https://hitpayapp.com/cal.com</a></p>",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
@@ -9,7 +9,7 @@
"categories": ["calendar"],
"publisher": "Cal.com, Inc.",
"email": "help@cal.com",
"description": "Import events from an ICS Feed into Cal.com.",
"description": "Import events from an ICS Feed into [Cal.com](https://cal.com).",
"isTemplate": false,
"__createdUsingCli": true
}
+3 -3
View File
@@ -1,8 +1,8 @@
---
items:
- 1.png
- 2.png
- 3.png
- 1.webp
- 2.webp
- 3.webp
---
{DESCRIPTION}
+1 -1
View File
@@ -9,7 +9,7 @@
"categories": ["automation"],
"publisher": "Valentin Chmara",
"email": "valentinchmara@gmail.com",
"description": "Enhance your scheduling and appointment management experience with the Intercom Integration for Cal.com.",
"description": "Enhance your scheduling and appointment management experience with the Intercom Integration for [Cal.com](https://cal.com).",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

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