Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a5d8b261 | ||
|
|
dfc022d890 | ||
|
|
68cb0cf197 | ||
|
|
14592de6c4 | ||
|
|
d41dcc69ad | ||
|
|
0a28b5825b | ||
|
|
3131318b6a | ||
|
|
08e35e4698 | ||
|
|
ed4b3f082a | ||
|
|
a1007398f6 | ||
|
|
2aafa1c163 | ||
|
|
008178480a | ||
|
|
2334c3bef8 | ||
|
|
9542116ec1 | ||
|
|
f9c11c2856 | ||
|
|
30060f03eb | ||
|
|
b8ffcef45e | ||
|
|
999882b795 | ||
|
|
bda963b851 | ||
|
|
8511494d68 | ||
|
|
1c3ad9dad6 | ||
|
|
d43afe7dbd | ||
|
|
a06b2009ab | ||
|
|
2a786b4652 | ||
|
|
ef1844557f | ||
|
|
d1f7b043e8 | ||
|
|
3a8e40d029 | ||
|
|
bca46228e7 | ||
|
|
d15b24b8ea | ||
|
|
d9df9b3d01 | ||
|
|
fc1d6ae402 | ||
|
|
26b4b7a868 | ||
|
|
a839630ca3 | ||
|
|
e21dbb704e |
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
echo "Updating submodules recursively"
|
||||
pwd
|
||||
git submodule update --init --recursive
|
||||
@@ -0,0 +1,7 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Skip if `.gitmodules` exists
|
||||
[ -f .gitmodules ] && {
|
||||
echo ".gitmodules already initialized"
|
||||
exit 0
|
||||
}
|
||||
|
||||
./git-setup.sh website console
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
<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 +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",
|
||||
};
|
||||
};
|
||||
@@ -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 |
@@ -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: </strong>e.g. PayNow, DuitNow, QRIS, PromptPay</span><br/><span><strong data-stringify-type=\"bold\">E-Wallets: </strong>e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay</span><br/><span><strong data-stringify-type=\"bold\">Credit & Debit Cards: </strong>e.g. Visa, Mastercard, American Express, UnionPay</span><br/><span><strong data-stringify-type=\"bold\">Buy Now, Pay Later: </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 <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: </strong>e.g. PayNow, DuitNow, QRIS, PromptPay</span><br/><span><strong data-stringify-type=\"bold\">E-Wallets: </strong>e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay</span><br/><span><strong data-stringify-type=\"bold\">Credit & Debit Cards: </strong>e.g. Visa, Mastercard, American Express, UnionPay</span><br/><span><strong data-stringify-type=\"bold\">Buy Now, Pay Later: </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 <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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
- 3.png
|
||||
- 1.webp
|
||||
- 2.webp
|
||||
- 3.webp
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 160 KiB |