Compare commits

..
31 Commits
Author SHA1 Message Date
GitHub Actions 8511494d68 chore: release v5.3.5
Post release / release (push) Canceled after 0s
2025-05-25 00:08:53 +00:00
Benny Joo 1c3ad9dad6 refactor: make routing forms code more maintainable (#21478)
* create layout for routing/forms

* refactor

* refators

* fix

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

* fix: global e2e fialure

* update

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

* Update util.ts

* tweak

* Update delete.handler.ts

* add test

* update

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

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

* chore: fix TypeScript 5.8.3 compatibility issues

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

* Uncomment `optmizeImage`

* Type fix

* Type fix

---------

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

* feat: create new route to fetch download link

* tests: add unit test

* fix: unit test

* fix: type error

* chore: add env var

* fix: open in new tab

* fix: type err

* chore: update test

* fix: import

* chore: use cal video recording token secret

---------

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

* docs: managed users dont have cal.com page

* docs: reassign works only for round robin bookings

* refactor: put OAuthClientWebhooksController behind ApiAuthGuard

* fix: tests

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

* update

* Update booking.e2e.ts

* update

* Update booking.e2e.ts

* Update booking.e2e.ts
2025-05-23 12:40:27 +05:30
Hariom Balhara e21dbb704e fix error in selected-calendars cron for delegation credential (#21465) 2025-05-23 03:37:24 +00:00
GitHub Actions 53a06797a9 chore: release v5.3.4
Post release / release (push) Canceled after 0s
2025-05-22 23:03:42 +00:00
Carina WollendorferandCarinaWolli 99eb743772 add verifiedAt timestamp (#21477)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-22 22:42:25 +00:00
73adfb0cec fix: header in routing forms (#21445)
* fix header

* remove unused variables

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-22 19:47:10 +00:00
Rajiv SahalandMorgan 7127c60116 fix: stripe integration in EventTypeSettings atom (#20919)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-22 12:18:28 -07:00
Joe Au-Yeung a6d37fd167 feat: Round robin, when attendee books same timeslot return original booking (#21455) 2025-05-22 12:06:28 -07:00
Benny Joo 16ea15076d fix: increase timeout to 800 seconds for workflow reminders (#21476) 2025-05-22 18:31:02 +00:00
Anik Dhabal Babu 041871f1b6 fix: only fixed hosts added (#21407) 2025-05-22 18:14:46 +00:00
Carina WollendorferandCarinaWolli 8c8ae127fe feat: allow custom SMS templates for free users (#21469)
* charge free users for SMS to CA an US

* allow custom template for free users

* fix backend + some UI fixes

* make text editable

* rename back to needsTeamsUpgrade

* add twilio webhook handler tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-22 19:09:49 +01:00
357c82129c feat: Dub App integration (#21321)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-22 09:18:45 -07:00
Eunjae Lee 324ac691eb fix: use local date range on /insights (based on profile's timezone) (#21472)
* fix: use local date range on /insights (based on profile's timezone)

* update test case
2025-05-22 16:03:56 +00:00
devin-ai-integration[bot]andeunjae@cal.com da99001a7a - Prefer the implementation in packages/trpc/react/hooks as it's used by most of the codebase (#21468)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-22 11:24:58 -04:00
Anik Dhabal Babu bd10d07083 fix: delete dilog button not visible (#21470) 2025-05-22 14:42:35 +00:00
Hariom BalharaandUdit Takkar 0e9b419a07 fix: Delegation Credentials - Use Cal Video if set as the default conferencing app (#21431)
* fix google-meet being used instead of Default Cal Video preference if
set

* Review addressed

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-22 08:21:25 -04:00
Hariom Balhara d5bd08d279 improve-delegation-disable-email-communication (#21456) 2025-05-22 09:06:31 +00:00
devin-ai-integration[bot]andeunjae@cal.com 4cc83530f9 refactor(e2e): use filter helper methods instead of raw selectors (#21460)
* refactor(e2e): use filter helper methods instead of raw selectors

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(e2e): rename selectFilter to addFilter and update all usages

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-22 08:46:51 +00:00
Hariom BalharaandOmar López 4fcf4081d0 feat: Send emails to potentially affected Organization Members on disabling delegation credential (#21276)
* mvp

* Improve UI

* wip

* self-review

* self-review

---------

Co-authored-by: Omar López <zomars@me.com>
2025-05-22 03:28:21 +00:00
162 changed files with 2860 additions and 572 deletions
+4
View File
@@ -445,6 +445,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
# Used to enable quick availability checks for x% of all visitors
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
# Used to generate token for cal video recordings expiry token
# You can use: `openssl rand -base64 32` to generate one
CAL_VIDEO_RECORDING_TOKEN_SECRET=
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
-21
View File
@@ -1,21 +0,0 @@
name: Submodule Sync
on:
schedule:
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
- cron: "15 */4 * * *"
workflow_dispatch: ~
jobs:
submodule-sync:
name: Submodule update
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Commit
run: |
git config user.email "actions@github.com"
git config user.name "github-actions"
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "Updating submodules recursively"
pwd
git submodule update --init --recursive
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
/** No watch related fields support in API v1 */
watchAttempts: true,
unwatchAttempts: true,
maxAttempts: true,
});
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
@@ -38,7 +38,8 @@ describe("GET /api/bookings", async () => {
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
});
it("Returns bookings for regular user", async () => {
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
+1 -1
View File
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.8.2"
"typescript": "^5.8.3"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -290,7 +290,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to auto-selected host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -311,7 +312,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to a specific host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -108,6 +108,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) {
@@ -226,20 +226,26 @@ export class EventTypesAtomService {
} else {
credentials = credentials.concat(teamAppCredentials);
}
} else {
if (slug !== "stripe") {
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
const { credentials: allCredentials } =
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
user: {
...user,
credentials,
},
});
credentials = allCredentials;
}
}
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
const enabledApps = await getEnabledAppsFromCredentials(
credentials as unknown as CredentialDataWithTeamName[],
{
user: {
...user,
credentials,
},
where: { slug },
}
);
credentials = allCredentials;
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
where: { slug },
});
const apps = await Promise.all(
enabledApps
.filter(({ ...app }) => app.slug === slug)
@@ -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,
+33 -2
View File
@@ -1526,6 +1526,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,
@@ -5442,6 +5452,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,
@@ -6819,6 +6839,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 +7281,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 +7332,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",
@@ -15029,6 +15059,7 @@
},
"address": {
"type": "string",
"minLength": 1,
"example": "123 Example St, City, Country"
},
"public": {
@@ -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>
);
}
@@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
import { handleCreateSelectedCalendars } from "../route";
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
// Mock GoogleCalendarService
const fetchPrimaryCalendarMock = vi.fn();
@@ -148,23 +148,50 @@ describe("handleCreateSelectedCalendars integration", () => {
]);
});
it("creates a Selected Calendar even if the user's primary calendar ID does not match their email address", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
describe("when the user's primary calendar ID does not match their email address", () => {
it("creates a Selected Calendar with the primaryCalendarId as the externalId if user's email is not a plus based variant of primaryCalendarId", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
await expectSelectedCalendars([
{ userId: user.id, externalId: "notuser@example.com", delegationCredentialId: delegationCredential.id },
]);
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{
userId: user.id,
externalId: "notuser@example.com",
delegationCredentialId: delegationCredential.id,
},
]);
});
it("creates a Selected Calendar with the user's email as the externalId if user's email is a plus based variant of primaryCalendarId", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1+test@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{ userId: user.id, externalId: user.email, delegationCredentialId: delegationCredential.id },
]);
});
});
it("does not create duplicate Selected Calendars for users who already have a valid one", async () => {
@@ -274,3 +301,35 @@ describe("handleCreateSelectedCalendars integration", () => {
});
});
});
describe("isSameEmail", () => {
it("returns true if variant email is a plus based variant of main email", () => {
expect(
isSameEmail({
mainEmail: "user1@example.com",
variantEmail: "user1+test@example.com",
emailProvider: "google",
})
).toBe(true);
});
it("returns false if variant email is different from main email", () => {
expect(
isSameEmail({
mainEmail: "user1@example.com",
variantEmail: "user1@domain.com",
emailProvider: "google",
})
).toBe(false);
});
it("returns true if variant email is same as main email but with different casing", () => {
expect(
isSameEmail({
mainEmail: "User1@example.com",
variantEmail: "user1+test@Example.com",
emailProvider: "google",
})
).toBe(true);
});
});
@@ -29,6 +29,25 @@ const validateRequest = (req: NextRequest) => {
}
};
export function isSameEmail({
mainEmail,
variantEmail,
emailProvider,
}: {
mainEmail: string;
variantEmail: string;
emailProvider: "google";
}) {
if (emailProvider !== "google") {
return mainEmail.toLowerCase() === variantEmail.toLowerCase();
}
const variantEmailParts = variantEmail.split("@");
const variantEmailUserName = variantEmailParts[0];
const variantEmailDomain = variantEmailParts[1];
const variantEmailUserNameWithoutPlus = variantEmailUserName.split("+")[0];
return mainEmail.toLowerCase() === `${variantEmailUserNameWithoutPlus}@${variantEmailDomain}`.toLowerCase();
}
type DelegationUserCredential = Awaited<
ReturnType<typeof CredentialRepository.findAllDelegationByTypeIncludeUserAndTake>
>[number];
@@ -156,6 +175,9 @@ async function fetchPrimaryCalendarId({
return primaryCalendarId;
}
/**
* Creates a SelectedCalendar record corresponding to Delegation User Credential - that would normally use primary calendar Id as the externalId but in some cases, it would use user's email as the externalId
*/
async function processDelegationUserCredential(
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
) {
@@ -166,30 +188,47 @@ async function processDelegationUserCredential(
return;
}
const externalCalendarId = await fetchPrimaryCalendarId({
const primaryCalendarId = await fetchPrimaryCalendarId({
googleCalendarService,
delegationUserCredential,
});
if (!externalCalendarId) {
if (!primaryCalendarId) {
log.error(
`External calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`
);
throw new Error("externalCalendarId could not be determined");
}
if (externalCalendarId !== userEmail) {
// We don't know the scenario when Delegation Credential allows access to an email that is not the primary calendar email
// So log a warning for further investigation
log.warn(
`External calendar id mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify({
externalCalendarId,
userEmail,
})
);
let externalCalendarId = primaryCalendarId;
if (primaryCalendarId !== userEmail) {
const areEmailsSame = isSameEmail({
emailProvider: "google",
mainEmail: primaryCalendarId,
variantEmail: userEmail,
});
if (areEmailsSame) {
log.info(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}. Using userEmail as the externalCalendarId as it is a plus based variant of primaryCalendarId`
);
// We have found that for some plus based email addresses(like john+test@acme.com) fetching primary calendar is successful and the primaryCalendarId comes back as john@acme.com
// Using userEmail as the externalCalendarId would ensure that the SelectedCalendar isn't attempted to be re-created and the SelectedCalendar record is still logically correct
externalCalendarId = userEmail;
} else {
// Unknown reason for mismatch, so we log it.
log.warn(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify({
externalCalendarId,
userEmail,
})
);
}
}
// This creation call isn't expected to fail as we have already checked for the existence of the SelectedCalendar by userEmail
await SelectedCalendarRepository.create({
integration: "google_calendar",
externalId: externalCalendarId,
+2 -1
View File
@@ -192,11 +192,12 @@ async function getHandler(request: NextRequest) {
try {
const response = await fetch(filteredLogo);
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
let buffer: Buffer = Buffer.from(arrayBuffer);
// If we need to resize the team logos (via Next.js' built-in image processing)
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
buffer = await optimizeImage({
buffer,
contentType: detectContentType(buffer) ?? "image/jpeg",
@@ -9,15 +9,16 @@ import {
sendDailyVideoRecordingEmails,
sendDailyVideoTranscriptEmails,
} from "@calcom/emails/daily-video-emails";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import {
getAllTranscriptsAccessLinkFromMeetingId,
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/lib/videoClient";
import { generateVideoToken } from "@calcom/lib/videoTokens";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
@@ -26,7 +27,6 @@ import {
meetingEndedSchema,
recordingReadySchema,
batchProcessorJobFinishedSchema,
downloadLinkSchema,
testRequestSchema,
} from "@calcom/web/lib/daily-webhook/schema";
import {
@@ -44,10 +44,9 @@ const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHead
return computed_signature;
};
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const getProxyDownloadLinkOfCalVideo = async (recordingId: string) => {
const token = generateVideoToken(recordingId);
const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`;
return downloadLink;
};
@@ -110,7 +109,7 @@ export async function postHandler(request: NextRequest) {
},
});
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
const downloadLink = await getProxyDownloadLinkOfCalVideo(recording_id);
const teamId = await getTeamIdFromEventType({
eventType: {
@@ -192,7 +191,7 @@ export async function postHandler(request: NextRequest) {
const evt = await getCalendarEvent(booking);
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
const recording = await getProxyDownloadLinkOfCalVideo(input.recordingId);
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
await triggerTranscriptionGeneratedWebhook({
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { describe, expect, test, vi, afterEach } from "vitest";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
import { GET } from "../route";
vi.mock("@calcom/lib/videoClient", () => ({
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
}));
vi.mock("@calcom/lib/videoTokens", () => ({
verifyVideoToken: vi.fn(),
}));
describe("GET /api/video/recording", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("returns 401 when token is missing", async () => {
const request = new Request("http://example.com/api/video/recording");
const response = await GET(request);
expect(response.status).toBe(401);
expect(await response.text()).toBe("Missing token");
});
test("returns 401 when token is invalid", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: false });
const request = new Request("http://example.com/api/video/recording?token=invalid_token");
const response = await GET(request);
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid or expired token");
});
test("returns 404 when recording is not found", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue(undefined);
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Recording not found");
});
test("redirects to download link when recording exists", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link: "https://example.com/download",
});
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response instanceof NextResponse).toBe(true);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://example.com/download");
});
test("returns 500 when getting recording fails", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockRejectedValue(new Error("Failed to get recording"));
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Failed to get recording");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");
if (!token) {
return new Response("Missing token", { status: 401 });
}
const verification = verifyVideoToken(token);
if (!verification.valid || !verification.recordingId) {
return new Response("Invalid or expired token", { status: 401 });
}
try {
const recordingLink = await getDownloadLinkOfCalVideoByRecordingId(verification.recordingId);
if (!recordingLink) {
return new Response("Recording not found", { status: 404 });
}
return NextResponse.redirect(recordingLink.download_link, { status: 302 });
} catch (error) {
console.error("Failed to get recording:", error);
return new Response("Failed to get recording", { status: 500 });
}
}
@@ -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,
+1 -1
View File
@@ -1,4 +1,4 @@
import useMeQuery from "./useMeQuery";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
export const useCurrentUserId = () => {
const query = useMeQuery();
-13
View File
@@ -1,13 +0,0 @@
import { trpc } from "@calcom/trpc/react";
export function useMeQuery() {
const meQuery = trpc.viewer.me.get.useQuery(undefined, {
retry(failureCount) {
return failureCount > 3;
},
});
return meQuery;
}
export default useMeQuery;
@@ -21,14 +21,13 @@ import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
import useMeQuery from "@lib/hooks/useMeQuery";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.3.3",
"version": "5.3.5",
"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,
+10 -9
View File
@@ -76,7 +76,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const creditService = new CreditService();
if (countryCode === "US" || countryCode === "CA") {
// SMS to US and CA are free
// SMS to US and CA are free for teams
let teamIdToCharge = parsedTeamId;
if (!teamIdToCharge && parsedUserId) {
@@ -92,15 +92,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
teamIdToCharge = teamMembership?.teamId;
}
await creditService.chargeCredits({
teamId: teamIdToCharge,
userId: !teamIdToCharge ? parsedUserId : undefined,
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
});
if (teamIdToCharge) {
await creditService.chargeCredits({
teamId: teamIdToCharge,
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
});
return res.status(200).send(`SMS to US and CA are free. Credits set to 0`);
return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`);
}
}
let orgId;
+9 -12
View File
@@ -5,6 +5,7 @@ import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { addFilter, openFilter } from "./filter-helpers";
import { createTeamEventType } from "./fixtures/users";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
@@ -76,9 +77,8 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await addFilter(page, "dateRange");
await openFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
@@ -243,9 +243,8 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/past`);
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await addFilter(page, "dateRange");
await openFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
@@ -354,9 +353,8 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-userId"]').click();
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
await addFilter(page, "userId");
await openFilter(page, "userId");
const bookingsGetResponse2 = page.waitForResponse(
(response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200
@@ -461,9 +459,8 @@ test.describe("Bookings", () => {
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
await bookingsGetResponse1;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-userId"]').click();
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
await addFilter(page, "userId");
await openFilter(page, "userId");
await page
.locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${anotherUser}")`)
.click();
+5 -5
View File
@@ -2,9 +2,9 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
/**
* Select a filter from the filter dropdown
* Add a filter from the filter dropdown
*/
export async function selectFilter(page: Page, columnId: string) {
export async function addFilter(page: Page, columnId: string) {
await page.getByTestId("add-filter-button").click();
await page.getByTestId(`add-filter-item-${columnId}`).click();
}
@@ -19,7 +19,7 @@ export async function openFilter(page: Page, columnId: string) {
export async function applySelectFilter(page: Page, columnId: string, value: string) {
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
if (!(await existingFilter.isVisible())) {
await selectFilter(page, columnId);
await addFilter(page, columnId);
}
await openFilter(page, columnId);
await selectOptionValue(page, columnId, value);
@@ -33,7 +33,7 @@ export async function selectOptionValue(page: Page, columnId: string, value: str
export async function applyTextFilter(page: Page, columnId: string, operator: string, operand?: string) {
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
if (!(await existingFilter.isVisible())) {
await selectFilter(page, columnId);
await addFilter(page, columnId);
}
await openFilter(page, columnId);
@@ -55,7 +55,7 @@ export async function applyTextFilter(page: Page, columnId: string, operator: st
export async function applyNumberFilter(page: Page, columnId: string, operator: string, operand: number) {
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
if (!(await existingFilter.isVisible())) {
await selectFilter(page, columnId);
await addFilter(page, columnId);
}
await openFilter(page, columnId);
+1 -1
View File
@@ -395,7 +395,7 @@ export const createUsersFixture = (
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: "https://cal.com" },
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from "uuid";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import {
selectFilter,
addFilter,
openFilter,
clearFilters,
applySelectFilter,
@@ -286,7 +286,7 @@ test.describe("Insights > Routing Filters", () => {
await page.goto(`/insights/routing`);
await selectFilter(page, "bookingAttendees");
await addFilter(page, "bookingAttendees");
await openFilter(page, "bookingAttendees");
await page.getByPlaceholder("Filter attendees by name or email").fill("John");
await page.keyboard.press("Enter");
@@ -295,7 +295,7 @@ test.describe("Insights > Routing Filters", () => {
await expect(page.getByText("Response 2")).toBeHidden();
await clearFilters(page);
await selectFilter(page, "bookingAttendees");
await addFilter(page, "bookingAttendees");
await openFilter(page, "bookingAttendees");
await page.getByPlaceholder("Filter attendees by name or email").fill("jane.smith@example");
await page.keyboard.press("Enter");
@@ -304,7 +304,7 @@ test.describe("Insights > Routing Filters", () => {
await expect(page.getByText("Response 1")).toBeHidden();
await clearFilters(page);
await selectFilter(page, "bookingAttendees");
await addFilter(page, "bookingAttendees");
await openFilter(page, "bookingAttendees");
await page.getByPlaceholder("Filter attendees by name or email").fill("JANE");
await page.keyboard.press("Enter");
+4 -4
View File
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
@@ -202,14 +203,13 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await page.getByTestId("add-filter-button").click();
await page.getByTestId("add-filter-item-bookingUserId").click();
await addFilter(page, "bookingUserId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Click User filter to see a user list
await page.getByTestId("filter-popover-trigger-bookingUserId").click();
await openFilter(page, "bookingUserId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
@@ -226,7 +226,7 @@ test.describe("Insights", async () => {
// press escape button to close the filter
await page.keyboard.press("Escape");
await page.getByTestId("clear-filters-button").click();
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
});
@@ -333,7 +333,7 @@ test.describe("Bookings", () => {
// Second booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, { email: "attendee1@test.com" });
await expect(page.getByTestId("success-page")).toBeVisible();
const secondHost = await page.getByTestId("booking-host-name").textContent();
expect(secondHost).not.toBeNull();
@@ -342,7 +342,7 @@ test.describe("Bookings", () => {
// Third booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, { email: "attendee2@test.com" });
await expect(page.getByTestId("success-page")).toBeVisible();
const thirdHost = await page.getByTestId("booking-host-name").textContent();
expect(thirdHost).not.toBeNull();
@@ -352,7 +352,7 @@ test.describe("Bookings", () => {
// Fourth booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, { email: "attendee3@test.com" });
await expect(page.getByTestId("success-page")).toBeVisible();
const fourthHost = await page.getByTestId("booking-host-name").textContent();
expect(fourthHost).not.toBeNull();
+7 -10
View File
@@ -7,6 +7,7 @@ import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { addFilter, openFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
import { localize } from "./lib/localize";
import { submitAndWaitForResponse } from "./lib/testUtils";
@@ -657,8 +658,7 @@ test.describe("Out of office", () => {
await page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await addFilter(page, "dateRange");
await expect(
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
).toBeVisible();
@@ -672,9 +672,8 @@ test.describe("Out of office", () => {
await page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await addFilter(page, "dateRange");
await openFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible(); //Last 7 Days
@@ -777,11 +776,10 @@ test.describe("Out of office", () => {
//Default filter 'Last 7 Days' when DateRange Filter is selected
await test.step("Default filter - 'Last 7 Days'", async () => {
await page.locator('[data-testid="add-filter-button"]').click();
await addFilter(page, "dateRange");
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await entriesListRespPromise;
//1 OOO record should be visible for member3, end=currentDate-4days
@@ -839,14 +837,13 @@ test.describe("Out of office", () => {
//Select 'Last 30 Days'
await test.step("select 'Last 30 Days'", async () => {
await page.locator('[data-testid="add-filter-button"]').click();
await addFilter(page, "dateRange");
const entriesListRespPromise1 = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await entriesListRespPromise1;
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await openFilter(page, "dateRange");
const entriesListRespPromise2 = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
@@ -1599,6 +1599,7 @@
"team_url": "Team URL",
"team_members": "Team members",
"more": "More",
"and_count_more": "and {{count}} more",
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complicated actions, please refer back to the web application.",
"workflow_example_1": "Send SMS reminder 24 hours before event starts to attendee",
"workflow_example_2": "Send custom SMS when event is rescheduled to attendee",
@@ -3221,5 +3222,7 @@
"requires_credits": "Requires credits",
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
"available_credits": "Available credits",
"members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+9
View File
@@ -26,6 +26,15 @@
}
],
"functions": {
"app/api/cron/workflows/scheduleEmailReminders/route.ts": {
"maxDuration": 800
},
"app/api/cron/workflows/scheduleSMSReminders/route.ts": {
"maxDuration": 800
},
"app/api/cron/workflows/scheduleWhatsappReminders/route.ts": {
"maxDuration": 800
},
"pages/api/trpc/workflows/[trpc].ts": {
"maxDuration": 800
},
+33 -2
View File
@@ -1466,6 +1466,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,
@@ -5208,6 +5218,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,
@@ -6519,6 +6539,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 +6959,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 +7008,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",
@@ -14135,6 +14165,7 @@
},
"address": {
"type": "string",
"minLength": 1,
"example": "123 Example St, City, Country"
},
"public": {
@@ -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>
@@ -42,7 +42,7 @@ title: "Build a greeter app"
import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui/button/Button";
import useMeQuery from "@lib/hooks/useMeQuery";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
export default function GreeterButton() {
const { data: user } = useMeQuery();
+3
View File
@@ -34,6 +34,9 @@ After creating a “managed user” you will receive the user ID, an access and
#### What is it not?
It's important to clarify that a "managed user" is completely independent of a regular user account on cal.com, meaning you don't need your users to register on cal.com web application.
❗Managed users do not get a public cal.com page like normal cal.com users do aka if you create a managed user the managed user won't have cal.com/managed-user page, because the point of platform solution is to integrate scheduling into your platform,
so instead the managed user public page will be at your-platform.com/managed-user and there you fetch event types of the managed user using our api or react hooks and display them.
### Create managed users via our API
We now need the OAuth client’s “client ID” and “client secret” that you can find in [https://app.cal.com/settings/organizations/platform/oauth-clients](https://app.cal.com/settings/organizations/platform/oauth-clients).
+1 -19
View File
@@ -29,12 +29,6 @@ Note: `docker compose` without the hyphen is now the primary method of using doc
cd calcom-docker
```
3. Update the calcom submodule
```
git submodule update --remote --init
```
4. Rename `.env.example` to `.env` and then update `.env`
5. Build and start Cal.com via docker compose
@@ -67,20 +61,8 @@ These variables must be provided at the time of the docker build, and can be pro
* NEXTAUTH_SECRET
### Git Submodules
This repository uses a git submodule.
To update the calcom submodule, use the following command:
```
git submodule update --remote --init
```
For more advanced usage, please refer to the git documentation: [https://git-scm.com/book/en/v2/Git-Tools-Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules)
### Troubleshooting
* SSL edge termination: If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
* Failed to commit changes: Invalid 'prisma.user.create()': Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
* Failed to commit changes: Invalid 'prisma.user.create()': Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
# Skip if `.gitmodules` exists
[ -f .gitmodules ] && {
echo ".gitmodules already initialized"
exit 0
}
./git-setup.sh website console
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
# If no project name is given
if [ $# -eq 0 ]; then
# Display usage and stop
echo "Usage: git-setup.sh <console,website>"
exit 1
fi
# Get remote url to support either https or ssh
remote_url=$(echo $(git config --get remote.origin.url) | sed 's![^/]*$!!')
# Loop through the requested modules
for module in "$@"; do
echo "Setting up '$module' module..."
# Set the project git URL
project=$remote_url$module.git
# Check if we have access to the module
if [ "$(git ls-remote "$project" 2>/dev/null)" ]; then
echo "You have access to '${module}'"
# Create the .gitmodules file if it doesn't exist
([ -e ".gitmodules" ] || touch ".gitmodules") && [ ! -w ".gitmodules" ] && echo cannot write to .gitmodules && exit 1
# Prevents duplicate entries
git config -f .gitmodules --unset-all "submodule.apps/$module.branch"
# Add the submodule
git submodule add --force $project "apps/$module"
# Determine the branch based on module
branch="main"
if [ "$module" = "website" ]; then
branch="production"
fi
# Set the default branch
git config -f .gitmodules --add "submodule.apps/$module.branch" ${branch}
# Update to the latest of branch in that submodule
cd apps/$module && git pull origin ${branch} && cd ../..
# We forcefully added the subdmoule which was in .gitignore, so unstage it.
git restore --staged apps/$module
else
echo "You don't have access to: '${module}' module."
fi
done
git restore --staged .gitmodules
+1 -1
View File
@@ -108,7 +108,7 @@
"prismock": "^1.33.4",
"resize-observer-polyfill": "^1.5.1",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"vitest": "^2.1.1",
"vitest-fetch-mock": "^0.3.0",
"vitest-mock-extended": "^2.0.2"
+1 -1
View File
@@ -31,6 +31,6 @@
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
"typescript": "^5.8.3"
}
}
+51
View File
@@ -0,0 +1,51 @@
import logger from "@calcom/lib/logger";
import type { AnalyticsService, AnalyticsServiceClass } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
import appStore from "..";
const log = logger.getSubLogger({ prefix: ["AnalyticsManager"] });
interface AnalyticsApp {
lib: {
AnalyticsService: AnalyticsServiceClass;
};
}
const isAnalyticsService = (x: unknown): x is AnalyticsApp =>
!!x &&
typeof x === "object" &&
"lib" in x &&
typeof x.lib === "object" &&
!!x.lib &&
"AnalyticsService" in x.lib;
export const getAnalyticsService = async ({
credential,
}: {
credential: CredentialPayload;
}): Promise<AnalyticsService | null> => {
if (!credential || !credential.key) return null;
const { type: analyticsType } = credential;
const analyticsName = analyticsType.split("_")[0];
const analyticsAppImportFn = appStore[analyticsName as keyof typeof appStore];
if (!analyticsAppImportFn) {
log.warn(`analytics app not implemented`);
return null;
}
const analyticsApp = await analyticsAppImportFn();
if (!isAnalyticsService(analyticsApp)) {
log.warn(`Analytics is not implemented`);
return null;
}
const AnalyticsService = analyticsApp.lib.AnalyticsService;
log.info("Got analyticsApp", AnalyticsService);
return new AnalyticsService(credential);
};
@@ -21,6 +21,7 @@ import deel_config_json from "./deel/config.json";
import demodesk_config_json from "./demodesk/config.json";
import dialpad_config_json from "./dialpad/config.json";
import discord_config_json from "./discord/config.json";
import dub_config_json from "./dub/config.json";
import eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json";
import elevenlabs_config_json from "./elevenlabs/config.json";
@@ -127,6 +128,7 @@ export const appStoreMetadata = {
demodesk: demodesk_config_json,
dialpad: dialpad_config_json,
discord: discord_config_json,
dub: dub_config_json,
eightxeight: eightxeight_config_json,
"element-call": element_call_config_json,
elevenlabs: elevenlabs_config_json,
@@ -21,6 +21,7 @@ export const apiHandlers = {
demodesk: import("./demodesk/api"),
dialpad: import("./dialpad/api"),
discord: import("./discord/api"),
dub: import("./dub/api"),
eightxeight: import("./eightxeight/api"),
"element-call": import("./element-call/api"),
elevenlabs: import("./elevenlabs/api"),
+16
View File
@@ -0,0 +1,16 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
- 4.jpeg
---
{DESCRIPTION}
## How to Get Started
1. **Log in** to your [Dub.co](https://dub.co) account by installing the app.
2. **Set up the [Client SDK](https://dub.co/docs/sdks/client-side/introduction)** on your website:
- Add `app.cal.com` to your [Outbound Domains](https://dub.co/docs/sdks/client-side/features/cross-domain-tracking).
3. Once a **booking event** occurs, your tracking data will be captured automatically.
+37
View File
@@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
import { dubAppKeysSchema, scopeString } from "../lib/utils";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const loggedInUser = req.session?.user;
if (!loggedInUser) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
// Ideally this should never happen, as email is there in session user but typings aren't accurate it seems
// TODO: So, confirm and later fix the typings
if (!loggedInUser.email) {
throw new HttpError({ statusCode: 400, message: "Session user must have an email" });
}
const { client_id, redirect_uris } = await getParsedAppKeysFromSlug("dub", dubAppKeysSchema);
const url = new URL("https://app.dub.co/oauth/authorize");
url.searchParams.append("client_id", client_id);
url.searchParams.append("redirect_uri", redirect_uris);
url.searchParams.append("response_type", "code");
url.searchParams.append("scope", scopeString);
const oauthUrl = url.toString();
return res.status(200).json({ url: oauthUrl });
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
+76
View File
@@ -0,0 +1,76 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { dubAppKeysSchema } from "../lib/utils";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const state = decodeOAuthState(req);
if (typeof code !== "string") {
if (state?.onErrorReturnTo || state?.returnTo) {
res.redirect(
getSafeRedirectUrl(state.onErrorReturnTo) ??
getSafeRedirectUrl(state?.returnTo) ??
`${WEBAPP_URL}/apps/installed`
);
return;
}
throw new HttpError({ statusCode: 400, message: "`code` must be a string" });
}
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
const { client_id, redirect_uris, client_secret } = await getParsedAppKeysFromSlug("dub", dubAppKeysSchema);
const codeExchangeUrl = `https://api.dub.co/oauth/token`;
const result = await fetch(codeExchangeUrl, {
method: "POST",
body: new URLSearchParams({
code,
client_id,
redirect_uri: redirect_uris,
client_secret,
grant_type: "authorization_code",
}).toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (result.status !== 200) {
let errorMessage = "Something wrong with Dub Api";
try {
const responseBody = await result.json();
if (typeof responseBody?.error?.message === "string") {
errorMessage = responseBody.error.message;
}
} catch (err) {
return;
}
return res.status(400).json({ message: errorMessage });
}
const responseBody = await result.json();
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
await createOAuthAppCredential({ appId: "dub", type: "dub" }, responseBody, req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "analytics", slug: "dub" })
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
+16
View File
@@ -0,0 +1,16 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Dub",
"slug": "dub",
"type": "dub_analytics",
"logo": "icon-dark.svg",
"url": "https://dub.co",
"variant": "analytics",
"categories": ["analytics"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic"
}
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,108 @@
import { Dub } from "dub-package";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { DubOAuthToken } from "./type";
export default class DubService implements AnalyticsService {
private dubClient?: Dub;
private client_id = "";
private client_secret = "";
private log = logger.getSubLogger({ prefix: ["[[lib]] dub"] });
private credential: CredentialPayload;
constructor(credential: CredentialPayload) {
this.credential = credential;
this.client_id = "";
this.client_secret = "";
}
private async initClient() {
const appKeys = await getAppKeysFromSlug("dub");
const { client_id, client_secret } = appKeys;
if (!client_id || !client_secret) {
this.log.error("Dub.co app keys missing!");
return;
}
this.client_id = client_id as string;
this.client_secret = client_secret as string;
let token = this.credential.key as unknown as DubOAuthToken | undefined;
if (!token) return;
const isTokenExpired = (token: DubOAuthToken) => {
if (!token || !token.access_token) return true;
if (token.expiry_date) {
return token.expiry_date < Date.now();
}
return false;
};
if (isTokenExpired(token)) {
token = await this.refreshAccessToken(token.refresh_token);
if (!token) return;
}
this.dubClient = new Dub({ token: token.access_token });
}
private async refreshAccessToken(refreshToken: string): Promise<DubOAuthToken | undefined> {
try {
if (!refreshToken) return;
const newToken: DubOAuthToken = await refreshOAuthTokens(
async () => {
const response = await fetch(`https://api.dub.co/oauth/token`, {
method: "POST",
body: new URLSearchParams({
client_id: this.client_id,
client_secret: this.client_secret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}).toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return await response.json();
},
"dub",
this.credential.userId
);
newToken.expiry_date = Date.now() + newToken.expires_in * 1000;
await CredentialRepository.updateCredentialById({
id: this.credential.id,
data: { key: newToken as any },
});
return newToken;
} catch (err) {
this.log.error(err);
}
}
async sendEvent({ name, email, eventName, id, externalId }: SendEventProps): Promise<void> {
await this.initClient();
if (!this.dubClient) return;
await this.dubClient.track.lead({
clickId: id,
customerName: name,
customerEmail: email,
externalId: externalId ?? email,
eventName: eventName ?? "Cal.com lead",
});
}
}
+1
View File
@@ -0,0 +1 @@
export { default as AnalyticsService } from "./AnalyticsService";
+8
View File
@@ -0,0 +1,8 @@
export interface DubOAuthToken {
access_token: string;
refresh_token: string;
token_type: "Bearer";
expires_in: number;
expiry_date?: number;
scope: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
export const dubAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
redirect_uris: z.string(),
});
const dubScope = ["workspaces.read"];
export const scopeString = dubScope.join(",");
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/dub",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*",
"dub-package": "npm:dub@^0.61.12"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

@@ -0,0 +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="#000"/></svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -974,15 +974,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 +998,6 @@ export default class GoogleCalendarService implements Calendar {
googleChannelResourceId: googleChannelProps.resourceId,
googleChannelResourceUri: googleChannelProps.resourceUri,
googleChannelExpiration: googleChannelProps.expiration,
error,
},
eventTypeIds
);
+1 -1
View File
@@ -3,7 +3,7 @@
"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"],

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+1
View File
@@ -4,6 +4,7 @@ const appStore = {
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
closecom: createCachedImport(() => import("./closecom")),
dailyvideo: createCachedImport(() => import("./dailyvideo")),
dub: createCachedImport(() => import("./dub")),
googlecalendar: createCachedImport(() => import("./googlecalendar")),
googlevideo: createCachedImport(() => import("./googlevideo")),
hubspot: createCachedImport(() => import("./hubspot")),
@@ -6,14 +6,12 @@ import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RoutingFormWithResponseCount } from "@calcom/routing-forms/types/types";
import { trpc } from "@calcom/trpc";
import { Button } from "@calcom/ui/components/button";
import { DropdownMenuSeparator } from "@calcom/ui/components/dropdown";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { enabledIncompleteBookingApps } from "../../lib/enabledIncompleteBookingApps";
import { FormAction, FormActionsDropdown } from "../FormActions";
import { FormSettingsSlideover } from "./FormSettingsSlideover";
@@ -111,17 +109,15 @@ const Actions = ({
StartIcon="external-link">
{t("view_form")}
</FormAction>
{isMobile ? (
<FormAction
action="incompleteBooking"
className="w-full"
routingForm={form}
color="minimal"
type="button"
StartIcon="calendar">
{t("routing_incomplete_booking_tab")}
</FormAction>
) : null}
<FormAction
action="incompleteBooking"
className="w-full"
routingForm={form}
color="minimal"
type="button"
StartIcon="calendar">
{t("routing_incomplete_booking_tab")}
</FormAction>
<FormAction
action="copyLink"
className="w-full"
@@ -223,14 +219,6 @@ export function Header({
const [title, setTitle] = useState(routingForm.name);
const form = useFormContext<RoutingFormWithResponseCount>();
const { data } = trpc.viewer.appRoutingForms.getIncompleteBookingSettings.useQuery({
formId: routingForm.id,
});
const showIncompleteBookingTab = data?.credentials.some((credential) =>
enabledIncompleteBookingApps.includes(credential?.appId ?? "")
);
const { getCurrentPage, handleNavigation } = useRoutingFormNavigation(
routingForm,
appUrl,
@@ -357,15 +345,6 @@ export function Header({
label: t("routing"),
iconLeft: <Icon name="waypoints" className="h-3 w-3" />,
},
...(showIncompleteBookingTab
? [
{
value: "incomplete-booking",
label: t("routing_incomplete_booking_tab"),
iconLeft: <Icon name="calendar" className="h-3 w-3" />,
},
]
: []),
]}
/>
</div>
@@ -1,7 +1,6 @@
export const routingFormsComponents = {
"form-edit": () => import("./form-edit/[...appPages]").then((mod) => mod.default),
"route-builder": () => import("./route-builder/[...appPages]").then((mod) => mod.default),
forms: () => import("./forms/[...appPages]").then((mod) => mod.default),
"routing-link": () => import("./routing-link/[...appPages]").then((mod) => mod.default),
reporting: () => import("./reporting/[...appPages]").then((mod) => mod.default),
"incomplete-booking": () => import("./incomplete-booking/[...appPages]").then((mod) => mod.default),
@@ -3,11 +3,9 @@ import "server-only";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import { getServerSidePropsForSingleFormView as getServerSidePropsSingleForm } from "../components/getServerSidePropsSingleForm";
import { getServerSideProps as getServerSidePropsForms } from "./forms/getServerSideProps";
import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps";
export const routingServerSidePropsConfig: Record<string, AppGetServerSideProps> = {
forms: getServerSidePropsForms,
"form-edit": getServerSidePropsSingleForm,
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
@@ -26,13 +26,10 @@ import { Icon } from "@calcom/ui/components/icon";
import { List, ListLinkItem } from "@calcom/ui/components/list";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { SetNewFormDialogState, NewFormDialogState } from "../../components/FormActions";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "../../components/FormActions";
import { isFallbackRoute } from "../../lib/isFallbackRoute";
import type { RoutingFormWithResponseCount } from "../../types/types";
import { getServerSideProps } from "./getServerSideProps";
function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNewFormDialogState }) {
const { t } = useLocale();
@@ -47,11 +44,7 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
);
}
export default function RoutingForms({
appUrl,
}: inferSSRProps<typeof getServerSideProps> & {
appUrl: string;
}) {
export default function RoutingForms({ appUrl }: { appUrl: string }) {
const { t } = useLocale();
const { hasPaidPlan } = useHasPaidPlan();
const routerQuery = useRouterQuery();
@@ -332,5 +325,3 @@ export default function RoutingForms({
</LicenseRequired>
);
}
export { getServerSideProps };
@@ -1,20 +0,0 @@
import type { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser
) {
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
return {
props: {},
};
};
@@ -262,7 +262,7 @@ test.describe("Routing Forms", () => {
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", "https://cal.com");
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
@@ -576,7 +576,7 @@ test.describe("Routing Forms", () => {
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("External Redirect");
expect(route).toBe("https://cal.com");
expect(route).toBe("https://cal.com/peer");
await page.click('[data-testid="close-results-button"]');
// Multiselect(Legacy)
@@ -925,7 +925,7 @@ test.describe("Routing Forms", () => {
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", "https://cal.com");
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
await saveCurrentForm(page);
return {
formId,
+6
View File
@@ -0,0 +1,6 @@
declare module "@calcom/config/next-i18next.config" {
export const i18n: {
locales: string[];
defaultLocale: string;
};
}
+1 -1
View File
@@ -33,6 +33,6 @@
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.4"
"typescript": "^5.8.3"
}
}
+23
View File
@@ -50,6 +50,7 @@ import type { ChangeOfEmailVerifyLink } from "./templates/change-account-email-v
import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify";
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
import DelegationCredentialDisabledEmail from "./templates/delegation-credential-disabled-email";
import DisabledAppEmail from "./templates/disabled-app-email";
import type { Feedback } from "./templates/feedback-email";
import FeedbackEmail from "./templates/feedback-email";
@@ -835,3 +836,25 @@ export const sendCreditBalanceLimitReachedEmails = async ({
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user }));
}
};
export const sendDelegationCredentialDisabledEmail = async ({
recipientEmail,
recipientName,
calendarAppName,
conferencingAppName,
}: {
recipientEmail: string;
recipientName?: string;
calendarAppName: string;
conferencingAppName: string;
}) => {
await sendEmail(
() =>
new DelegationCredentialDisabledEmail({
recipientEmail,
recipientName,
calendarAppName,
conferencingAppName,
})
);
};
@@ -0,0 +1,76 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import BaseEmail from "./_base-email";
export default class DelegationCredentialDisabledEmail extends BaseEmail {
recipientEmail: string;
recipientName?: string;
calendarAppName: string;
conferencingAppName: string;
constructor({
recipientEmail,
recipientName,
calendarAppName,
conferencingAppName,
}: {
recipientEmail: string;
recipientName?: string;
calendarAppName: string;
conferencingAppName: string;
}) {
super();
this.name = "DELEGATION_CREDENTIAL_DISABLED";
this.recipientEmail = recipientEmail;
this.recipientName = recipientName;
this.calendarAppName = calendarAppName;
this.conferencingAppName = conferencingAppName;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.recipientEmail,
subject: `You might need to connect your ${this.calendarAppName}`,
html: await this.getHtml(),
text: this.getTextBody(),
};
}
async getHtml() {
return `
<div style="font-family: Arial, sans-serif;">
<h2>Action Required: Connect your ${this.calendarAppName}</h2>
<p>
${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
</p>
<p>
An admin has disabled Delegation Credential for your organization. You may need to connect your <b>${
this.calendarAppName
}</b> to use Cal.com effectively. Connecting your calendar is crucial as it allows us to check for conflicts to prevent double bookings and automatically add new events to your schedule.
</p>
<p>
Please log in to your Cal.com account and connect your calendar from your settings page by visiting: <a href="${WEBAPP_URL}/settings/my-account/calendars">${WEBAPP_URL}/settings/my-account/calendars</a>.
</p>
<p>
Additionally, please ensure you have the ${
this.conferencingAppName
} app installed. You can manage your conferencing apps here: <a href="${WEBAPP_URL}/settings/my-account/conferencing">${WEBAPP_URL}/settings/my-account/conferencing</a>.
</p>
<p>
If you have already connected your calendar and installed ${
this.conferencingAppName
}, you can ignore this message.
</p>
<p>
Thank you,<br />The Cal.com Team
</p>
</div>
`;
}
protected getTextBody(): string {
return `Action Required: Connect your ${this.calendarAppName}\n\nAn admin has disabled Delegation Credential for your organization. You may need to connect your ${this.calendarAppName} manually to continue receiving bookings and updates, and to allow Cal.com to check for conflicts to prevent double bookings and automatically add new events to your schedule.\n\nPlease log in to your Cal.com account and connect your calendar from your settings page by visiting: ${WEBAPP_URL}/settings/my-account/calendars.\n\nAdditionally, please ensure you have the ${this.conferencingAppName} app installed. You can manage your conferencing apps here: ${WEBAPP_URL}/settings/my-account/conferencing.\n\nIf you have already connected your calendar and installed ${this.conferencingAppName}, you can ignore this message.\n\nThank you,\nThe Cal.com Team`;
}
}
+1 -1
View File
@@ -54,7 +54,7 @@
"npm-run-all": "^4.1.5",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"vite": "^4.1.2",
"vite-plugin-environment": "^1.1.3"
}
+1 -1
View File
@@ -50,7 +50,7 @@
"@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.34.0",
"npm-run-all": "^4.1.5",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"vite": "^4.5.2"
},
"dependencies": {
+1 -1
View File
@@ -25,7 +25,7 @@
],
"types": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"vite": "^4.1.2"
},
"dependencies": {
+1 -1
View File
@@ -8,7 +8,7 @@
"@typescript-eslint/parser": "^5.52.0",
"@typescript-eslint/utils": "^5.52.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/eslint": "^8.4.5"
+1 -1
View File
@@ -1,4 +1,4 @@
import { Dub } from "dub";
import { Dub } from "dub-package";
export const dub = new Dub({
token: process.env.DUB_API_KEY,
-1
View File
@@ -5,7 +5,6 @@ import { getToken } from "next-auth/jwt";
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
// @ts-expect-error no type definitions
import { i18n } from "@calcom/config/next-i18next.config";
/**
@@ -1053,7 +1053,7 @@ export const getOptions = ({
dub.track.lead({
clickId,
eventName: "Sign Up",
customerId: user.id.toString(),
externalId: user.id.toString(),
customerName: user.name,
customerEmail: user.email,
customerAvatar: user.image,
@@ -60,24 +60,42 @@ const getImageUrlAvatarAPI = async (email: string) => {
return null;
}
const response = await fetch("https://avatarapi.com/v2/api.aspx", {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: JSON.stringify({
username: process.env.AVATARAPI_USERNAME,
password: process.env.AVATARAPI_PASSWORD,
email: email,
}),
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const info = await response.json();
try {
const response = await fetch("https://avatarapi.com/v2/api.aspx", {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: JSON.stringify({
username: process.env.AVATARAPI_USERNAME,
password: process.env.AVATARAPI_PASSWORD,
email,
}),
signal: controller.signal,
});
if (!info.Success) {
console.log("Error from avatar api: ", info.Error);
clearTimeout(timeout);
const info = await response.json();
if (!info.Success) {
if (info.Error === "Not found") {
// Expected case: no avatar for this email
return null;
}
console.warn("Avatar API error:", info.Error);
return null;
}
return info.Image as string;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
console.warn("Avatar API request timed out");
} else {
console.error("Avatar API request failed:", error);
}
return null;
}
return info.Image as string;
};
@@ -60,6 +60,7 @@ export const mapBookingToMutationInput = ({
const _isDryRun = isBookingDryRun(searchParams);
const _cacheParam = searchParams?.get("cal.cache");
const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
const dub_id = searchParams?.get("dub_id");
return {
...values,
@@ -91,6 +92,7 @@ export const mapBookingToMutationInput = ({
reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined,
_isDryRun,
_shouldServeCache,
dub_id,
};
};
@@ -44,6 +44,7 @@ import {
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import EventManager from "@calcom/lib/EventManager";
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import {
@@ -65,6 +66,7 @@ import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFre
import { safeStringify } from "@calcom/lib/safeStringify";
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
import { getTranslation } from "@calcom/lib/server/i18n";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
@@ -109,7 +111,6 @@ import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
import { validateEventLength } from "./handleNewBooking/validateEventLength";
import handleSeats from "./handleSeats/handleSeats";
import { getBookingRequest } from "./requiresConfirmation/getBookingRequest";
const translator = short();
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
@@ -275,17 +276,16 @@ export const buildEventForTeamEventType = async ({
throw new Error("Scheduling type is required for team event type");
}
const teamDestinationCalendars: DestinationCalendar[] = [];
const fixedUsers = users.filter((user) => user.isFixed);
const nonFixedUsers = users.filter((user) => !user.isFixed);
const filteredUsers =
schedulingType === SchedulingType.ROUND_ROBIN
? [...fixedUsers, ...(nonFixedUsers.length > 0 ? [nonFixedUsers[0]] : [])]
: users;
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = users
.filter((user) => {
if (user.email === organizerUser.email) return false;
// Skip non-fixed users in ROUND_ROBIN team event
if (schedulingType === SchedulingType.ROUND_ROBIN && !user.isFixed) return false;
return true;
})
const teamMemberPromises = filteredUsers
.filter((user) => user.email !== organizerUser.email)
.map(async (user) => {
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
// push to teamDestinationCalendars if it's a team event but collective only
@@ -489,20 +489,24 @@ async function handler(
bookerEmail,
});
// If a request already exists for the timeslot, return that request
if (!isConfirmedByDefault && !userReschedulingIsOwner) {
const requestedBooking = await getBookingRequest({
// For unconfirmed bookings or round robin bookings with the same attendee and timeslot, return the original booking
if (
(!isConfirmedByDefault && !userReschedulingIsOwner) ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
const existingBooking = await BookingRepository.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
bookerPhoneNumber,
startTime: new Date(dayjs(reqBody.start).utc().format()),
filterForUnconfirmed: !isConfirmedByDefault,
});
if (requestedBooking) {
if (existingBooking) {
const bookingResponse = {
...requestedBooking,
...existingBooking,
user: {
...requestedBooking.user,
...existingBooking.user,
email: null,
},
paymentRequired: false,
@@ -2160,6 +2164,18 @@ async function handler(
loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error }));
}
if (!isDryRun) {
await handleAnalyticsEvents({
credentials: allCredentials,
rawBookingData,
bookingInfo: {
name: fullName,
email: bookerEmail,
eventName: "Cal.com lead",
},
});
}
// TODO: Refactor better so this booking object is not passed
// all around and instead the individual fields are sent as args.
const bookingResponse = {
@@ -15,7 +15,7 @@ const sortUsersByDynamicList = <TUser extends { username: string | null }>(
});
};
const _getLocationValuesForDb = <
export const _getLocationValuesForDb = <
TUser extends {
username: string | null;
metadata: Prisma.JsonValue;
@@ -42,9 +42,15 @@ const _getLocationValuesForDb = <
credentials: firstDynamicGroupMember.credentials,
});
const defaultConferencingApp = firstDynamicGroupMemberMetadata?.defaultConferencingApp;
const hasMemberSetConferencingPreference =
!!defaultConferencingApp?.appSlug || !!defaultConferencingApp?.appLink;
firstDynamicGroupMemberDefaultLocationUrl =
firstDynamicGroupMemberMetadata?.defaultConferencingApp?.appLink ||
firstDynamicGroupMemberDelegationCredentialConferencingAppLocation;
(hasMemberSetConferencingPreference
? defaultConferencingApp?.appLink
: firstDynamicGroupMemberDelegationCredentialConferencingAppLocation) ?? null;
locationBodyString = firstDynamicGroupMemberDefaultLocationUrl || locationBodyString;
}
@@ -104,11 +104,11 @@ describe("buildEventForTeamEventType", () => {
expect(withDestinationCalendarArgs).toHaveLength(0);
});
it("excludes non-fixed users for ROUND_ROBIN", async () => {
it("includes one non-fixed user for ROUND_ROBIN when fixed users exist", async () => {
await buildEventForTeamEventType({
existingEvent: {},
users: [
baseUser({ id: 2, isFixed: false, email: "notfixed@example.com" }),
baseUser({ id: 2, isFixed: false, email: "nonfixed@example.com" }),
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
],
organizerUser: { email: "organizer@example.com" },
@@ -119,7 +119,27 @@ describe("buildEventForTeamEventType", () => {
const memberEmails = teamArgs.members.map((m: any) => m.email);
expect(memberEmails).toContain("fixed@example.com");
expect(memberEmails).not.toContain("notfixed@example.com");
expect(memberEmails).toContain("nonfixed@example.com");
});
it("includes only the first non-fixed user for ROUND_ROBIN when multiple exist", async () => {
await buildEventForTeamEventType({
existingEvent: {},
users: [
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
baseUser({ id: 2, isFixed: false, email: "nonfixed1@example.com" }),
baseUser({ id: 4, isFixed: false, email: "nonfixed2@example.com" }),
],
organizerUser: { email: "organizer@example.com" },
schedulingType: SchedulingType.ROUND_ROBIN,
});
const teamArgs = withTeamSpy.mock.calls[0][0];
const memberEmails = teamArgs.members.map((m: any) => m.email);
expect(memberEmails).toContain("fixed@example.com");
expect(memberEmails).toContain("nonfixed1@example.com");
expect(memberEmails).not.toContain("nonfixed2@example.com");
});
it("builds a team with fallback name and id", async () => {
@@ -925,5 +925,140 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
`should use Cal Video as the location if that is the default conferencing app set by the user. It must not use Google Meet coming from Delegation credential.`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
});
const payloadToMakePartOfOrganization = [
{
membership: {
accepted: true,
role: MembershipRole.ADMIN,
},
team: {
id: org.id,
name: "Test Org",
slug: "testorg",
},
},
];
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const groupUser1 = getOrganizer({
name: "group-user-1",
username: "group-user-1",
email: "group-user-1@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
selectedCalendars: [TestData.selectedCalendars.google],
teams: payloadToMakePartOfOrganization,
credentials: [],
destinationCalendar: TestData.selectedCalendars.google,
metadata: {
defaultConferencingApp: {
appSlug: "daily-video",
},
},
});
const groupUser2 = getOrganizer({
name: "group-user-2",
username: "group-user-2",
email: "group-user-2@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
selectedCalendars: [TestData.selectedCalendars.google],
teams: payloadToMakePartOfOrganization,
credentials: [],
destinationCalendar: TestData.selectedCalendars.google,
metadata: {
defaultConferencingApp: {
appSlug: "daily-video",
},
},
});
await createDelegationCredential(org.id);
await createBookingScenario(
getScenarioData({
eventTypes: [],
users: [groupUser1, groupUser2],
apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
// Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "GOOGLE_CALENDAR_EVENT_ID",
uid: "MOCK_ID",
appSpecificData: {
googleCalendar: {
hangoutLink: "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT",
},
},
},
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const mockBookingData = getMockRequestDataForDynamicGroupBooking({
data: {
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:30:00.000Z`,
eventTypeId: 0,
eventTypeSlug: "group-user-1+group-user-2",
user: "group-user-1+group-user-2",
responses: {
email: booker.email,
name: booker.name,
// location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
await expectBookingToBeInDatabase({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: null,
status: BookingStatus.ACCEPTED,
location: BookingLocations.CalVideo,
references: [
{
type: appStoreMetadata.dailyvideo.type,
// Verify Delegation credential was not used
delegationCredentialId: null,
},
],
iCalUID: createdBooking.iCalUID,
});
},
timeout
);
});
});
@@ -58,7 +58,7 @@ import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { CreationSource, WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
@@ -3280,6 +3280,126 @@ describe("handleNewBooking", () => {
});
});
describe("Returning original booking", () => {
test("Return the original booking if a request is made twice by the same attendee when a booking requires confirmation", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresConfirmation: true,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
});
await createBookingScenario(scenarioData);
const response = await handleNewBooking({
bookingData: mockBookingData,
});
const secondResponse = await handleNewBooking({
bookingData: mockBookingData,
});
expect(secondResponse.id).toEqual(response.id);
});
test("Return the original booking if request is made by the same attendee for a round robin event", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
requiresConfirmation: true,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
});
await createBookingScenario(scenarioData);
const response = await handleNewBooking({
bookingData: mockBookingData,
});
const secondResponse = await handleNewBooking({
bookingData: mockBookingData,
});
expect(secondResponse.id).toEqual(response.id);
});
});
describe("Blocked users", () => {
test("user is locked", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;

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