Compare commits

..
52 Commits
Author SHA1 Message Date
Omar López 2fab54546e chore: v4.6.11 (#17333)
release.yml / release (push) Canceled after 0s
2024-10-25 11:07:17 -07:00
Benny Joo 820230d61d fix: primaryCal is missing (#17332) 2024-10-25 17:39:18 +00:00
Omar López af363e975a fix: disables cache for tasker cron (#17331) 2024-10-25 10:19:37 -07:00
Udit Takkar 96571df308 fix: use UTC date in tasker (#17329) 2024-10-25 09:11:14 -07:00
Morgan 9868926ec1 chore: webhooks meeting started / ended with oauthClientId (#17324)
* chore: oauth webhooks meeting started / ended

* fixup! Merge branch 'main' into chore-webhooks-platform-meeting-started-ended
2024-10-25 13:28:49 +00:00
Lauris Skraucis 69ca57e45a fix: v2 event types booking fields & enable booker atom google-meet location (#17321)
* fix: v2 phone system booking field

* feat: atom support google-meet

* feat: unknown booking field return type

* tests

* bump platform libraries

* driveby: improve create-booking-input
2024-10-25 15:55:29 +03:00
Hariom Balhara 9157e1faf3 fix: 404s due to wrong booking link(without org domain) (#17318) 2024-10-25 08:18:08 +00:00
Steve Xu 87baf0363e fix(docs): Update contributors-guide.mdx (#17316) 2024-10-25 11:42:41 +05:30
Hariom Balhara 4c6f242345 fix: is Empty and is Not empty operators (#17317) 2024-10-25 04:46:46 +00:00
Keith WilliamsandOmar López 1c8cf62582 feat: add more routing forms data (#17229)
Co-authored-by: Omar López <zomars@me.com>
2024-10-25 02:00:37 +00:00
sean-brydon 52e1a58904 handle gracefully and log error (#17310) 2024-10-24 21:59:42 +00:00
Bailey PumfleetandBenny Joo b6cef49ee6 fix: app store title (#17307)
* Fix app store title

* refactor apps-view

* revert unneeded changes

* remove withoutMain

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-10-24 21:18:38 +00:00
sean-brydon af94c64f46 catch error when no users are found (#17308) 2024-10-24 20:55:10 +00:00
Alex van Andel e251a5afea chore: Release v4.6.10 (#17296)
release.yml / release (push) Canceled after 0s
2024-10-24 16:27:20 -03:00
62d162f001 fix: Salesforce if contact was created don't create new lead as well (#17299)
* fix: salesforce event creation duplicate error

* chore: message

* If contact was created, don't create a new lead

* Add boolean for no accountId

* Remove debug artifact

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-10-24 19:27:08 +00:00
Carina Wollendorfer 8811ebcc33 fix: workflow reminder emails on reassigned bookings (#17305) 2024-10-24 19:15:30 +00:00
Alex van Andel c0529a26c4 fix: Broken 'Build Atoms' workflow bash syntax (force merge) 2024-10-24 19:46:21 +01:00
Alex van Andel a28d9bb840 chore: push minor improvements to gh actions & yarn.lock (#17302)
* chore: push minor improvements to gh actions & yarn.lock

* Remove bash conditionals for readability
2024-10-24 18:05:20 +00:00
06ec89e787 feat: Org owners should be able to see all bookings in bookings tab. (#15314)
* feat: Org owners should be able to see all bookings in bookings tab.

* perf: optimize the query

* perf: make it a separate query

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
2024-10-24 14:57:38 -03:00
Benny Joo a4d733fb9d fix: Wrong primary calendar selected for Google Calendar (#17301) 2024-10-24 17:50:53 +00:00
Syed Ali Shahbaz c3d56f736a fix: insights event timeline perf and accuracy (#17218)
* aggregate multiple calls to one for events timeline

* raw query

* fix inside wrong val

* fix timeline view entirely

* fix avg event duration

* fix timeline

* type-fix

* fixes

* remove unnecessary comments
2024-10-24 17:46:28 +00:00
Lauris Skraucis c629d08a9c fix: v2 event type locations (#17288)
* refactor: split internal locations in separate file

* refactor: rename TransformedLocationsSchema to InternalLocationsSchema

* feat: add missing locations in internal schema

* refactor: explicit input locations only for input

* refactor: explicit output locations only for output

* fix: v2

* fix: atoms

* test: internal to api locations transformation

* e2e tests

* upgrade platform libraries
2024-10-24 17:21:17 +00:00
Udit Takkar 50a41109d0 chore: add event has been rescheduled title (#17286) 2024-10-24 17:18:55 +00:00
Udit Takkar ca5b83f876 fix: salesforce event creation duplicate error (#17292)
* fix: salesforce event creation duplicate error

* chore: message
2024-10-24 17:11:12 +00:00
Hariom Balhara c29e37a03f fix: Keep 'Verify new route' enabled in the initial state. (#17295) 2024-10-24 16:16:58 +00:00
Shrey TannaandAlex van Andel 14ae664941 fix:CityNotShownInOption (#17064)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-24 09:10:27 -04:00
sean-brydon 0949bbf167 feat: add availability shown in rr reassign (#17287)
* (feat) add availability status to select

* add logic for confirmation modal on unavailable host

* fix type error

* fix type error

* more type fixes

* fix types
2024-10-24 12:38:03 +01:00
Rajiv Sahal d0a1e90199 fix: platform dashboard buttons (#17284) 2024-10-24 16:25:32 +05:30
Morgan 077f29305d fix: default booking status filter booking.controller (#17282) 2024-10-24 09:02:37 +00:00
Udit Takkar 4c528afe65 chore: add logs for debugging tasker (#17283) 2024-10-24 08:31:03 +00:00
RaghulandAnik Dhabal Babu 57ed383554 fix: [CAL-4296] Fix Booking Text wrap issue for multi emails (#16578)
* [CAL-4296] Fix Booking Text wrap issue for multi emails

* Update Button.tsx

* Update Components.tsx

* Update Button.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-24 06:13:45 +00:00
Benny Joo d53cabd163 chore: improve perf for fetching bookings in insights (#17281) 2024-10-24 04:17:38 +00:00
Joe Au-Yeung 817d8738a9 feat: Salesforce - on booking, write to custom fields [CAL-4570] (#17257)
* Save and enable custom fields

* Write to custom fields on booking

* Add validations
2024-10-24 03:07:13 +00:00
d6dbbd1472 fix: new reassignment email for RR host reassignment (#17279)
* add new reassignment email

* improve reassign email

* fixes

* Minor copy fixup

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-23 22:57:15 +00:00
Benny Joo 4021801698 feat: attach booker name/email to insights CSV (#17280) 2024-10-23 22:55:11 +00:00
Keith Williams b38d8e7ae4 perf: New getTeamSchedule route with higher vCPUs (#17277) 2024-10-23 13:38:15 -07:00
Benny Joo 123a39bd9a fix: Attributes Not Updating (#17275) 2024-10-23 14:42:42 -04:00
Keith Williams 596dc3fbd7 perf: Load OOO in bulk for getSlots (#17270)
* perf: Load OOO in bulk for getSlots

* Added back the failsafe of running the query when no initial data is passed in

* Changed length check to be more explicit
2024-10-23 14:25:10 +00:00
Lauris Skraucis 058d58a6f3 revert chore: make rate limit logs verbose api-v2 (#17273)
This reverts commit e2c54b1fc4.
2024-10-23 14:17:54 +00:00
Anik Dhabal BabuandKeith Williams 4caec83c8f feat: add Dialpad conferencing (#15444)
* feat: Dialpad conferencing

* update

* final update

* refactor

* update

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-23 19:47:09 +05:30
sean-brydon 3ddef59761 feat: add no-show-to-edit-list (#17265) 2024-10-23 12:07:13 +01:00
Maximilian FranzkeandAnik Dhabal Babu c7edfd5bd3 fix(a11y): added description for icon buttons (#17184)
* fix(a11y): added description for icon buttons

* Update Header.tsx

* Revert "Update Header.tsx"

This reverts commit b12515c8ce2f652adbf441d7488b877a50c8b539.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-23 12:41:00 +05:30
Morgan 23d1a21e23 chore: remove json stringify from requestId logs api-v2 (#17263) 2024-10-23 07:05:31 +00:00
Morgan e2c54b1fc4 chore: make rate limit logs verbose api-v2 (#17262) 2024-10-23 06:53:51 +00:00
Ali Nawaz 622b2795f5 fix: correct typos in E-Mail Settings and Organizations sections .env.example (#17258)
- Corrected "Sengrid" to "Sendgrid" in E-Mail Settings.
- Fixed "sing-ups" to "sign-ups" in the Organizations section.
- Corrected "envrionment" to "environment" in LICENSE (DEPRECATED).
2024-10-23 04:08:31 +00:00
Anik Dhabal Babu c0298248dc fix: private team does not hide team members from event type page (#17099)
* fix: private team does not hide team members from event type page

* update

* Update
2024-10-23 09:24:06 +05:30
Omar López db07738514 chore: v4.6.9 (#17255)
release.yml / release (push) Canceled after 0s
2024-10-22 20:38:33 +00:00
Joe Au-Yeung 700f0aeb35 fix: Salesforce - Check for existing lead when "Account" is set (#17254) 2024-10-22 19:36:39 +00:00
Carina WollendorferandCarinaWolli f3fd043255 add enableOverflow (#17249)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-22 15:39:21 +00:00
a0e4580f4e fix: added aria-label to timezone input (#17241)
* fix: 17186 timezone accessibility fix

* Update common.json

* update

* Update EventMeta.tsx

---------

Co-authored-by: Shivam Bhatia <shivambhatia@Shivams-MacBook-Pro.local>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-10-22 15:29:06 +00:00
Keith Williams 659b60aac0 fix: API v1 hashedLink zod (#17248) 2024-10-22 15:07:02 +00:00
Lauris Skraucis 7131996722 fix: Booker atom weekly view (#17245) 2024-10-22 14:39:30 +00:00
116 changed files with 3315 additions and 1304 deletions
+3 -3
View File
@@ -16,7 +16,7 @@
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
@@ -128,7 +128,7 @@ GOOGLE_API_CREDENTIALS=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
# Sengrid
# Sendgrid
# Used for email reminders in workflows and internal sync services
SENDGRID_API_KEY=
SENDGRID_EMAIL=
@@ -262,7 +262,7 @@ ORGANIZATIONS_ENABLED=
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
# This variable should only be set to 1 or true if you want to autolink external provider sign-ups with
# existing organizations based on email domain address
ORGANIZATIONS_AUTOLINK=
@@ -83,11 +83,12 @@ jobs:
**/.turbo/**
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-api-v1-build.outputs.cache-hit == 'true'
run: echo "Cache hit for API v1 build. Skipping build."
- name: Run build
if: steps.cache-api-v1-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-api-v1-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for API v1 build. Skipping build."
else
yarn turbo run build --filter=@calcom/api...
fi
yarn turbo run build --filter=@calcom/api...
shell: bash
+10 -9
View File
@@ -49,14 +49,15 @@ jobs:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-api-v2-build.outputs.cache-hit == 'true'
run: echo "Cache hit for API v2 build. Skipping build."
- name: Run build
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-api-v2-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for API v2 build. Skipping build."
else
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
fi
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
shell: bash
+10 -9
View File
@@ -5,7 +5,7 @@ on:
jobs:
build:
name: Build atoms
name: Build Atoms
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
@@ -28,13 +28,14 @@ jobs:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-atoms-build.outputs.cache-hit == 'true'
run: echo "Cache hit for Atoms build. Skipping build."
- name: Run build
if: steps.cache-atoms-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for atoms build. Skipping build."
else
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
fi
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
shell: bash
+9 -8
View File
@@ -26,16 +26,17 @@ jobs:
path: |
**/docs/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
# Log cache hit
- name: Log Cache Hit
if: steps.cache-docs-build.outputs.cache-hit == 'true'
run: echo "Cache hit for Docs build. Skipping build."
- name: Run build
if: steps.cache-docs-build.outputs.cache-hit != 'true'
working-directory: docs
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for Docs build. Skipping build."
else
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
fi
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
shell: bash
+4 -1
View File
@@ -168,7 +168,10 @@ export const schemaEventTypeReadPublic = EventType.pick({
metadata: jsonSchema.nullable(),
customInputs: customInputSchema.array().optional(),
link: z.string().optional(),
hashedLink: z.object({ link: z.string() }).optional().nullable(),
hashedLink: z
.array(z.object({ link: z.string() }))
.optional()
.default([]),
bookingFields: eventTypeBookingFields.optional().nullable(),
})
);
+1 -1
View File
@@ -28,7 +28,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.51",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.54",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -111,8 +111,9 @@ export class BookingsController_2024_04_15 {
@Query() queryParams: GetBookingsInput_2024_04_15
): Promise<GetBookingsOutput_2024_04_15> {
const { filters, cursor, limit } = queryParams;
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
const bookings = await getAllUserBookings({
bookingListingByStatus: [filters.status],
bookingListingByStatus: [bookingListingByStatus],
skip: cursor ?? 0,
take: limit ?? 10,
filters,
@@ -915,7 +915,7 @@ describe("Event types Endpoints", () => {
});
});
describe("Handle legacy event-types booking fields", () => {
describe("Handle event-types booking fields", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
@@ -940,6 +940,7 @@ describe("Event types Endpoints", () => {
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false },
];
beforeAll(async () => {
@@ -1092,6 +1093,21 @@ describe("Event types Endpoints", () => {
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1207,6 +1223,21 @@ describe("Event types Endpoints", () => {
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1234,6 +1265,204 @@ describe("Event types Endpoints", () => {
});
});
it("should return event type with unknown bookingField", async () => {
const unknownSystemField = {
name: "unknown-whatever",
type: "unknown-whatever",
label: "your team",
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "FC Barcelona",
};
const eventTypeInput = {
title: "unknown field event type two",
description: "unknown field event type description two",
length: 40,
hidden: false,
slug: "unknown-field-type-two",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [unknownSystemField],
};
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
return request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(unknownSystemField),
},
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
describe("Handle event-types locations", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "locations-event-types-test-e2e@api.com";
const name = "bob-the-locations-builder";
const username = name;
let user: User;
let legacyEventTypeId1: number;
let legacyEventTypeId2: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name,
username,
});
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should return integration location with link and credentialId", async () => {
const eventTypeInput = {
title: "event type discord",
description: "event type description",
length: 40,
hidden: false,
slug: "discord-event-type",
locations: [
{
type: "integrations:discord_video",
link: "https://discord.com/users/100",
credentialId: 100,
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.locations).toEqual([
{
type: "integration",
integration: "discord-video",
link: eventTypeInput.locations[0].link,
credentialId: eventTypeInput.locations[0].credentialId,
},
]);
});
});
it("should return unsupported location", async () => {
const eventTypeInput = {
title: "event type not existing",
description: "event type description",
length: 40,
hidden: false,
slug: "not-existing-event-type",
locations: [
{
type: "this-type-does-not-exist",
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.locations).toEqual([
{ type: "unknown", location: JSON.stringify(eventTypeInput.locations[0]) },
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -7,8 +7,7 @@ import {
transformLocationsInternalToApi,
transformBookingFieldsInternalToApi,
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
InternalLocationSchema,
SystemField,
CustomField,
parseBookingLimit,
@@ -20,12 +19,16 @@ import {
transformEventTypeColorsInternalToApi,
parseEventTypeColor,
transformSeatsInternalToApi,
InternalLocation,
BookingFieldSchema,
} from "@calcom/platform-libraries";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
NoticeThresholdTransformedSchema,
EventTypeOutput_2024_06_14,
OutputUnknownLocation_2024_06_14,
OutputUnknownBookingField_2024_06_14,
} from "@calcom/platform-types";
type EventTypeRelations = {
@@ -118,7 +121,7 @@ export class OutputEventTypesService_2024_06_14 {
const locations = this.transformLocations(databaseEventType.locations);
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
? this.transformBookingFields(databaseEventType.bookingFields)
: [];
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
@@ -190,7 +193,20 @@ export class OutputEventTypesService_2024_06_14 {
transformLocations(locations: any) {
if (!locations) return [];
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
const knownLocations: InternalLocation[] = [];
const unknownLocations: OutputUnknownLocation_2024_06_14[] = [];
for (const location of locations) {
const result = InternalLocationSchema.safeParse(location);
if (result.success) {
knownLocations.push(result.data);
} else {
unknownLocations.push({ type: "unknown", location: JSON.stringify(location) });
}
}
return [...transformLocationsInternalToApi(knownLocations), ...unknownLocations];
}
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
@@ -201,10 +217,26 @@ export class OutputEventTypesService_2024_06_14 {
};
}
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
transformBookingFields(bookingFields: any) {
if (!bookingFields) return [];
return transformBookingFieldsInternalToApi(bookingFields);
const knownBookingFields: (SystemField | CustomField)[] = [];
const unknownBookingFields: OutputUnknownBookingField_2024_06_14[] = [];
for (const bookingField of bookingFields) {
const result = BookingFieldSchema.safeParse(bookingField);
if (result.success) {
knownBookingFields.push(result.data);
} else {
unknownBookingFields.push({
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(bookingField),
});
}
}
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}
transformRecurringEvent(recurringEvent: any) {
@@ -21,19 +21,15 @@ export class ResponseInterceptor implements NestInterceptor {
const { statusCode } = response;
const responseTime = Date.now() - startTime;
// Log response in JSON format
this.logger.log(
JSON.stringify({
requestId,
method,
url,
statusCode,
responseTime,
responseBody: data,
timestamp: new Date().toISOString(),
message: "Outgoing Response",
})
);
this.logger.log("Outgoing Response", {
requestId,
method,
url,
statusCode,
responseTime,
responseBody: data,
timestamp: new Date().toISOString(),
});
})
);
}
@@ -11,17 +11,14 @@ export class RequestIdMiddleware implements NestMiddleware {
req.headers["X-Request-Id"] = requestId;
const { method, headers, body: requestBody, baseUrl } = req;
this.logger.log(
JSON.stringify({
requestId,
method,
url: baseUrl,
headers,
requestBody,
timestamp: new Date().toISOString(),
message: "Incoming Request",
})
);
this.logger.log("Incoming Request", {
requestId,
method,
url: baseUrl,
headers,
requestBody,
timestamp: new Date().toISOString(),
});
next();
}
+278 -47
View File
@@ -5485,6 +5485,9 @@
},
"data": {
"$ref": "#/components/schemas/CreateManagedUserData"
},
"error": {
"type": "object"
}
},
"required": [
@@ -5849,7 +5852,7 @@
"refreshToken"
]
},
"AddressLocation_2024_06_14": {
"InputAddressLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -5871,7 +5874,7 @@
"public"
]
},
"LinkLocation_2024_06_14": {
"InputLinkLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -5893,7 +5896,7 @@
"public"
]
},
"IntegrationLocation_2024_06_14": {
"InputIntegrationLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -5905,7 +5908,8 @@
"type": "string",
"example": "cal-video",
"enum": [
"cal-video"
"cal-video",
"google-meet"
]
}
},
@@ -5914,7 +5918,7 @@
"integration"
]
},
"PhoneLocation_2024_06_14": {
"InputPhoneLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -6606,7 +6610,7 @@
"showAvailabilityCount"
]
},
"AttendeeAddressLocation_2024_06_14": {
"InputAttendeeAddressLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -6619,7 +6623,7 @@
"type"
]
},
"AttendeePhoneLocation_2024_06_14": {
"InputAttendeePhoneLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -6632,7 +6636,7 @@
"type"
]
},
"AttendeeDefinedLocation_2024_06_14": {
"InputAttendeeDefinedLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
@@ -6734,25 +6738,25 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/AddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/LinkLocation_2024_06_14"
"$ref": "#/components/schemas/InputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/IntegrationLocation_2024_06_14"
"$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeAddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeePhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeDefinedLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14"
}
]
}
@@ -6943,6 +6947,219 @@
"slug"
]
},
"OutputAddressLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"address",
"link",
"integration",
"phone",
"attendeeAddress",
"attendeePhone",
"attendeeDefined"
],
"example": "address",
"description": "only allowed value for type is `address`"
},
"address": {
"type": "string",
"example": "123 Example St, City, Country"
},
"public": {
"type": "boolean"
}
},
"required": [
"type",
"address",
"public"
]
},
"OutputLinkLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"address",
"link",
"integration",
"phone",
"attendeeAddress",
"attendeePhone",
"attendeeDefined"
],
"example": "link",
"description": "only allowed value for type is `link`"
},
"link": {
"type": "string",
"example": "https://customvideo.com/join/123456"
},
"public": {
"type": "boolean"
}
},
"required": [
"type",
"link",
"public"
]
},
"OutputIntegrationLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"address",
"link",
"integration",
"phone",
"attendeeAddress",
"attendeePhone",
"attendeeDefined",
"conferencing",
"unknown"
],
"example": "integration",
"description": "only allowed value for type is `integration`"
},
"integration": {
"type": "string",
"enum": [
"cal-video",
"google-meet",
"zoom",
"whereby-video",
"whatsapp-video",
"webex-video",
"telegram-video",
"tandem",
"sylaps-video",
"skype-video",
"sirius-video",
"signal-video",
"shimmer-video",
"salesroom-video",
"roam-video",
"riverside-video",
"ping-video",
"office365-video",
"mirotalk-video",
"jitsi",
"jelly-video",
"jelly-conferencing",
"huddle",
"facetime-video",
"element-call-video",
"eightxeight-video",
"discord-video",
"demodesk-video",
"campsite-conferencing",
"campfire-video",
"around-video"
],
"example": "cal-video"
},
"link": {
"type": "string"
},
"credentialId": {
"type": "number"
}
},
"required": [
"type",
"integration"
]
},
"OutputPhoneLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"address",
"link",
"integration",
"phone",
"attendeeAddress",
"attendeePhone",
"attendeeDefined"
],
"example": "phone",
"description": "only allowed value for type is `phone`"
},
"phone": {
"type": "string",
"example": "+37120993151"
},
"public": {
"type": "boolean"
}
},
"required": [
"type",
"phone",
"public"
]
},
"OutputConferencingLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"address",
"link",
"integration",
"phone",
"attendeeAddress",
"attendeePhone",
"attendeeDefined",
"conferencing",
"unknown"
],
"example": "conferencing",
"description": "only allowed value for type is `conferencing`"
}
},
"required": [
"type"
]
},
"OutputUnknownLocation_2024_06_14": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"address",
"link",
"integration",
"phone",
"attendeeAddress",
"attendeePhone",
"attendeeDefined",
"conferencing",
"unknown"
],
"example": "unknown",
"description": "only allowed value for type is `unknown`"
},
"location": {
"type": "string"
}
},
"required": [
"type",
"location"
]
},
"EmailDefaultFieldOutput_2024_06_14": {
"type": "object",
"properties": {
@@ -7885,16 +8102,22 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/AddressLocation_2024_06_14"
"$ref": "#/components/schemas/OutputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/LinkLocation_2024_06_14"
"$ref": "#/components/schemas/OutputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/IntegrationLocation_2024_06_14"
"$ref": "#/components/schemas/OutputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneLocation_2024_06_14"
"$ref": "#/components/schemas/OutputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputConferencingLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputUnknownLocation_2024_06_14"
}
]
}
@@ -8218,25 +8441,25 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/AddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/LinkLocation_2024_06_14"
"$ref": "#/components/schemas/InputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/IntegrationLocation_2024_06_14"
"$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeAddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeePhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeDefinedLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14"
}
]
}
@@ -9697,25 +9920,25 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/AddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/LinkLocation_2024_06_14"
"$ref": "#/components/schemas/InputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/IntegrationLocation_2024_06_14"
"$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeAddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeePhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeDefinedLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14"
}
]
}
@@ -10013,16 +10236,22 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/AddressLocation_2024_06_14"
"$ref": "#/components/schemas/OutputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/LinkLocation_2024_06_14"
"$ref": "#/components/schemas/OutputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/IntegrationLocation_2024_06_14"
"$ref": "#/components/schemas/OutputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneLocation_2024_06_14"
"$ref": "#/components/schemas/OutputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputConferencingLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputUnknownLocation_2024_06_14"
}
]
}
@@ -10433,25 +10662,25 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/AddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/LinkLocation_2024_06_14"
"$ref": "#/components/schemas/InputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/IntegrationLocation_2024_06_14"
"$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeAddressLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeePhoneLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/AttendeeDefinedLocation_2024_06_14"
"$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14"
}
]
}
@@ -11425,7 +11654,8 @@
"RECORDING_TRANSCRIPTION_GENERATED",
"OOO_CREATED",
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW"
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
"FORM_SUBMITTED_NO_EVENT"
]
},
"active": {
@@ -11501,7 +11731,8 @@
"RECORDING_TRANSCRIPTION_GENERATED",
"OOO_CREATED",
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW"
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
"FORM_SUBMITTED_NO_EVENT"
]
},
"active": {
@@ -12510,7 +12741,7 @@
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses.",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"example": {
"customField": "customValue"
}
+6
View File
@@ -1 +1,7 @@
export { GET } from "@calcom/features/tasker/api/cron";
/**
* This runs each minute and we need fresh data each time
* @see https://nextjs.org/docs/app/building-your-application/caching#opting-out-2
**/
export const revalidate = 0;
+1 -2
View File
@@ -4,7 +4,7 @@ import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import AppsPage, { LayoutWrapper } from "~/apps/apps-view";
import AppsPage from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -14,7 +14,6 @@ export const generateMetadata = async () => {
};
export default WithLayout({
getLayout: LayoutWrapper,
getData: withAppDirSsr(getServerSideProps),
Page: AppsPage,
});
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React from "react";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui";
@@ -11,7 +11,7 @@ type AppsLayoutProps = {
children: React.ReactNode;
actions?: (className?: string) => JSX.Element;
emptyStore?: boolean;
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
} & Omit<ComponentProps<typeof Shell>, "actions">;
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
const { t } = useLocale();
@@ -21,7 +21,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
if (session.status === "loading") return <></>;
return (
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<div className="flex flex-col xl:flex-row">
<main className="w-full">
{emptyStore ? (
@@ -37,6 +37,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
)}
</main>
</div>
</ShellMain>
</Shell>
);
}
+100 -9
View File
@@ -103,7 +103,7 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const {
t,
i18n: { language },
@@ -113,6 +113,7 @@ function BookingListItem(booking: BookingItemProps) {
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
@@ -268,6 +269,17 @@ function BookingListItem(booking: BookingItemProps) {
});
}
if (isBookingInPast) {
editBookingActions.push({
id: "no_show",
label: t("mark_as_no_show"),
onClick: () => {
setIsNoShowDialogOpen(true);
},
icon: "eye-off" as const,
});
}
let bookedActions: ActionType[] = [
{
id: "cancel",
@@ -417,12 +429,14 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
{isOpenReassignDialog && (
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
@@ -452,6 +466,14 @@ function BookingListItem(booking: BookingItemProps) {
timeFormat={userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={setIsNoShowDialogOpen}
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
@@ -983,7 +1005,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end p-2">
<div className="flex justify-end p-2 ">
<Button
data-testid="update-no-show"
color="secondary"
@@ -999,6 +1021,75 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
</Dropdown>
);
};
const NoShowAttendeesDialog = ({
attendees,
isOpen,
setIsOpen,
bookingUid,
}: {
attendees: AttendeeProps[];
isOpen: boolean;
setIsOpen: (value: boolean) => void;
bookingUid: string;
}) => {
const { t } = useLocale();
const [noShowAttendees, setNoShowAttendees] = useState(
attendees.map((attendee) => ({
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
}))
);
const noShowMutation = trpc.viewer.markNoShow.useMutation({
onSuccess: async (data) => {
const newValue = data.attendees[0];
setNoShowAttendees((old) =>
old.map((attendee) =>
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
)
);
showToast(t(data.message), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")}>
{noShowAttendees.map((attendee) => (
<form
key={attendee.id}
onSubmit={(e) => {
e.preventDefault();
noShowMutation.mutate({
bookingUid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
}}>
<div className="bg-muted flex items-center justify-between rounded-md px-4 py-2">
<span className="text-emphasis flex flex-col text-sm">
{attendee.name}
{attendee.email && <span className="text-muted">({attendee.email})</span>}
</span>
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
</Button>
</div>
</form>
))}
<DialogFooter>
<DialogClose>{t("done")}</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
@@ -1035,7 +1126,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end space-x-2 p-2">
<div className="flex justify-end space-x-2 p-2 ">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
+106 -54
View File
@@ -1,7 +1,8 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { components } from "react-select";
import { classNames } from "@calcom/lib";
import { ErrorCode } from "@calcom/lib/errorCodes";
@@ -15,6 +16,7 @@ import {
DialogClose,
DialogContent,
DialogFooter,
ConfirmationDialogContent,
showToast,
Select,
RadioGroup as RadioArea,
@@ -51,12 +53,14 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
{
label: "Loading...",
value: 0,
status: "unavailable",
},
];
return teamMembers.data?.map((member) => ({
label: member.name,
value: member.id,
status: member.status,
}));
}, [teamMembers]);
@@ -96,12 +100,19 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
},
});
const [showConfirmation, setShowConfirmation] = useState(false);
const handleSubmit = (values: FormValues) => {
if (values.reassignType === "round_robin") {
roundRobinReassignMutation.mutate({ teamId, bookingId });
} else {
if (values.teamMemberId) {
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
if (selectedMember && selectedMember.status === "unavailable") {
setShowConfirmation(true);
} else {
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
}
}
}
};
@@ -109,58 +120,99 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
const watchedReassignType = form.watch("reassignType");
return (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent title={t("reassign_round_robin_host")} description={t("reassign_to_another_rr_host")}>
{/* TODO add team member reassignment*/}
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
form.setValue("reassignType", val as "team_member" | "round_robin");
}}
className={classNames("mt-1 flex flex-col gap-4")}>
<RadioArea.Item
value="round_robin"
className={classNames("w-full text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="team_member"
className={classNames("text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
<>
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent
title={t("reassign_round_robin_host")}
description={t("reassign_to_another_rr_host")}
enableOverflow>
{/* TODO add team member reassignment*/}
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
form.setValue("reassignType", val as "team_member" | "round_robin");
}}
className={classNames("mt-1 flex flex-col gap-4")}>
<RadioArea.Item
value="round_robin"
className={classNames("w-full text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="team_member"
className={classNames("text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Select
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
options={teamMemberOptions}
onChange={(event) => {
form.setValue("teamMemberId", event?.value);
}}
/>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
{t("reassign")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Select
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
options={teamMemberOptions}
onChange={(event) => {
form.setValue("teamMemberId", event?.value);
}}
components={{
Option: ({ children, ...props }) => {
const status = props.data?.status;
return (
<components.Option {...props}>
<div className="flex items-center gap-2">
<div
className={classNames(
"h-2 w-2 rounded-full",
status === "available" ? "bg-green-500" : "bg-red-500"
)}
/>
{children}
</div>
</components.Option>
);
},
}}
/>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
{t("reassign")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<Dialog open={showConfirmation} onOpenChange={setShowConfirmation}>
<ConfirmationDialogContent
variety="warning"
title={t("confirm_reassign_unavailable")}
confirmBtnText={t("yes_reassign")}
cancelBtnText={t("cancel")}
onConfirm={() => {
const teamMemberId = form.getValues("teamMemberId");
if (!teamMemberId) {
return;
}
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId });
setShowConfirmation(false);
}}>
{t("reassign_unavailable_team_member_description")}
</ConfirmationDialogContent>
</Dialog>
</>
);
};
+12 -9
View File
@@ -278,7 +278,10 @@ const NewRoutingManager = ({
const router = useRouter();
const bookerUrl = useBookerUrl();
const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data?.map((member) => member.id) || null;
teamMembersMatchingAttributeLogic?.data?.map((member) => member.id).filter((id) => {
// We don't want to reroute to the same user who booked the booking
return id !== booking.user?.id;
}) || null;
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
const bookingEventType = booking.eventType;
@@ -696,10 +699,13 @@ const RerouteDialogContentAndFooter = ({
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
booking: TeamEventTypeBookingToReroute;
}) => {
const { data: responseWithForm, isPending: isRoutingFormLoading, error: formResponseFetchError } =
trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const {
data: responseWithForm,
isPending: isRoutingFormLoading,
error: formResponseFetchError,
} = trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const { t } = useLocale();
@@ -840,10 +846,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
<Button
onClick={verifyRoute}
data-testid="verify-new-route-button"
disabled={
reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS ||
isResponseFromOrganizerUnpopulated
}>
disabled={reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS}>
{t("verify_new_route")}
</Button>
</DialogFooter>
@@ -176,6 +176,11 @@ vi.mock("@calcom/trpc/react", () => ({
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
});
}),
@@ -244,10 +249,6 @@ async function mockMessageFromOpenedTab({ type, data }: { type: string; data: an
return messageReceivedPromise;
}
function correctResponses() {
fireEvent.click(screen.getByTestId("mock-form-update-response-button"));
}
function expectEventTypeInfoInCurrentRouting({
eventTypeText,
eventTypeHref,
@@ -281,7 +282,7 @@ function expectAttendeesInfoInCurrentRouting({ attendeesText }: { attendeesText:
const attendeesEl = screen.getByTestId("current-routing-status-attendees");
expect(attendeesEl).toHaveTextContent(attendeesText);
}
const userWhoBooked = { id: 1, name: "Test User", email: "user@example.com" };
const mockBooking = {
id: 1,
uid: "original-booking-uid",
@@ -298,7 +299,7 @@ const mockBooking = {
schedulingType: SchedulingType.ROUND_ROBIN,
title: "Test Event",
},
user: { id: 1, name: "Test User", email: "user@example.com" },
user: userWhoBooked,
routedFromRoutingFormReponse: { id: 1 },
status: BookingStatus.ACCEPTED, // Add this line
};
@@ -348,10 +349,8 @@ describe("RerouteDialog", () => {
// screen.logTestingPlaygroundURL()
});
test("verify_new_route button is disabled when form fields are not filled", async () => {
test("verify_new_route button is enabled even when form fields are not filled", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("verify_new_route")).toBeDisabled();
correctResponses();
expect(screen.getByText("verify_new_route")).toBeEnabled();
});
@@ -383,8 +382,6 @@ describe("RerouteDialog", () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
fireEvent.click(screen.getByText("verify_new_route"));
expectEventTypeInfoInReroutePreview({
@@ -404,8 +401,6 @@ describe("RerouteDialog", () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
await mockMessageFromOpenedTab({
@@ -421,6 +416,8 @@ describe("RerouteDialog", () => {
expect.objectContaining({
rescheduleUid: mockBooking.uid,
"cal.rerouting": "true",
// Shouldn't include the user who booked the booking
"cal.routedTeamMemberIds": "2",
"cal.reroutingFormResponses": JSON.stringify({
"company-size": {
value: "small",
@@ -443,13 +440,13 @@ describe("RerouteDialog", () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
// Shouldn't include the user who booked the booking
routedTeamMemberIds: [2],
reroutingFormResponses: {
"company-size": {
value: "small",
@@ -159,14 +159,14 @@ export const OAuthClientCard = ({
</div>
<div className="flex items-start gap-4">
<Button
className="bg-subtle hover:bg-emphasis text-white"
color="primary"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit/webhooks`)}>
Webhooks
</Button>
<Button
className="bg-subtle hover:bg-emphasis text-white"
color="secondary"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
-15
View File
@@ -3,7 +3,6 @@
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
@@ -96,17 +95,3 @@ export default function Apps({ categories, appStore, userAdminTeams }: PageProps
</AppsLayout>
);
}
export const LayoutWrapper = (page: React.ReactElement) => {
const { t } = useLocale();
return (
<Shell
title={t("app_store")}
description={t("app_store_description")}
withoutMain={true}
hideHeadingOnMobile>
{page}
</Shell>
);
};
@@ -366,6 +366,8 @@ export default function Success(props: PageProps) {
const isEventCancelled = isCancelled && !seatReferenceUid;
const isPastBooking = isBookingInPast;
const isRerouting = searchParams?.get("cal.rerouting") === "true";
const isRescheduled = bookingInfo?.rescheduled;
const successPageHeadline = (() => {
if (needsConfirmationAndReschedulable) {
return isRecurringBooking ? t("booking_submitted_recurring") : t("booking_submitted");
@@ -379,6 +381,10 @@ export default function Success(props: PageProps) {
return t("no_longer_attending");
}
if (isRescheduled) {
return t("your_event_has_been_rescheduled");
}
if (isEventCancelled) {
return t("event_cancelled");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.6.8",
"version": "4.6.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { highPerfRouter } from "@calcom/trpc/server/routers/viewer/highPerf/_router";
export default createNextApiHandler(highPerfRouter);
+1 -2
View File
@@ -3,12 +3,11 @@ import { getServerSideProps } from "@lib/apps/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/apps-view";
import Apps, { LayoutWrapper } from "~/apps/apps-view";
import Apps from "~/apps/apps-view";
const Page = (props: PageProps) => <Apps {...props} />;
Page.PageWrapper = PageWrapper;
Page.getLayout = LayoutWrapper;
export { getServerSideProps };
+17 -4
View File
@@ -39,10 +39,14 @@
"email_not_sent": "Error occurred while sending email",
"event_declined_subject": "Declined: {{title}} at {{date}}",
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
"event_reassigned_subject": "Reassigned: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
"event_reassigned_subtitle": "You will no longer have the event on your calendar and your round robin likelihood will not be negatively impacted",
"organizer": "Organizer",
"reassigned_to": "Reassigned to",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
"no_options_available": "No options available",
@@ -127,6 +131,7 @@
"your_meeting_has_been_booked": "Your meeting has been booked",
"event_type_has_been_rescheduled_on_time_date": "Your {{title}} has been rescheduled to {{date}}.",
"event_has_been_rescheduled": "Updated - Your event has been rescheduled",
"your_event_has_been_rescheduled": "Your event has been rescheduled",
"request_reschedule_subtitle": "{{organizer}} has canceled the booking and requested you to pick another time.",
"request_reschedule_title_organizer": "You have requested {{attendee}} to reschedule",
"request_reschedule_subtitle_organizer": "You have canceled the booking and {{attendee}} should pick a new booking time with you.",
@@ -2553,7 +2558,8 @@
"no_support_needed": "No Support Needed?",
"hide_support": "Hide Support",
"event_ratings": "Average Ratings",
"event_no_show": "Host No Show",
"event_no_show": "No-Show (Host)",
"event_no_show_guest": "No-Show (Guest)",
"recent_ratings": "Recent ratings",
"no_ratings": "No ratings submitted",
"no_ratings_description": "Add a workflow with 'Rating' to collect ratings after meetings",
@@ -2654,10 +2660,10 @@
"no_columns_found": "No columns found",
"salesforce_create_record_as": "On booking, add events on and new attendees as:",
"salesforce_lead": "Lead",
"salesforce_contact_under_account": "Contact under an account",
"salesforce_contact_under_account": "Contact under an account",
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"mass_assign_attributes": "Mass assign attributes",
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
@@ -2685,7 +2691,14 @@
"reschedule_with_different_timeslot": "Reschedule with different timeslot",
"we_dont_have_id_for_the_new_event": "We don't have ID for the new event. Please go to the Routing Form and save it again.",
"rerouted_booking_successfully_redirecting_to_booking_page": "Rerouted booking successfully. Redirecting to booking page",
"on_booking_write_to_event_object": "On booking, write to event object",
"field_name": "Field name",
"add_new_field": "Add new field",
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
"form_response_not_found": "Form response not found",
"confirm_reassign_unavailable": "Manual Reassign to unavailable host",
"reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?",
"yes_reassign": "Yes, reassign",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
}
+34 -94
View File
@@ -1,88 +1,28 @@
import { expect, beforeEach, afterEach, it, vi, describe } from "vitest";
import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone";
import { addTimezonesToDropdown, handleOptionLabel, filterBySearchText } from "@calcom/lib/timezone";
const cityData = [
{
city: "San Francisco",
timezone: "America/Argentina/Cordoba",
},
{
city: "Sao Francisco do Sul",
timezone: "America/Sao_Paulo",
},
{
city: "San Francisco de Macoris",
timezone: "America/Santo_Domingo",
},
{
city: "San Francisco Gotera",
timezone: "America/El_Salvador",
},
{
city: "San Francisco",
timezone: "America/Los_Angeles",
},
{
city: "Eastern Time - US & Canada",
timezone: "America/New_York",
},
{
city: "Pacific Time - US & Canada",
timezone: "America/Los_Angeles",
},
{
city: "Central Time - US & Canada",
timezone: "America/Chicago",
},
{
city: "Mountain Time - US & Canada",
timezone: "America/Denver",
},
{
city: "Atlantic Time - Canada",
timezone: "America/Halifax",
},
{
city: "Eastern European Time",
timezone: "Europe/Bucharest",
},
{
city: "Central European Time",
timezone: "Europe/Berlin",
},
{
city: "Western European Time",
timezone: "Europe/London",
},
{
city: "Australian Eastern Time",
timezone: "Australia/Sydney",
},
{
city: "Japan Standard Time",
timezone: "Asia/Tokyo",
},
{
city: "India Standard Time",
timezone: "Asia/Kolkata",
},
{
city: "Gulf Standard Time",
timezone: "Asia/Dubai",
},
{
city: "South Africa Standard Time",
timezone: "Africa/Johannesburg",
},
{
city: "Brazil Time",
timezone: "America/Sao_Paulo",
},
{
city: "Hawaii-Aleutian Standard Time",
timezone: "Pacific/Honolulu",
},
const timezonesFixture = [
{ label: "San Francisco", timezone: "America/Argentina/Cordoba" },
{ label: "San Francisco", timezone: "America/Los_Angeles" },
{ label: "Sao Francisco do Sul", timezone: "America/Sao_Paulo" },
{ label: "San Francisco de Macoris", timezone: "America/Santo_Domingo" },
{ label: "San Francisco Gotera", timezone: "America/El_Salvador" },
{ label: "Eastern Time - US & Canada", timezone: "America/New_York" },
{ label: "Pacific Time - US & Canada", timezone: "America/Los_Angeles" },
{ label: "Central Time - US & Canada", timezone: "America/Chicago" },
{ label: "Mountain Time - US & Canada", timezone: "America/Denver" },
{ label: "Atlantic Time - Canada", timezone: "America/Halifax" },
{ label: "Eastern European Time", timezone: "Europe/Bucharest" },
{ label: "Central European Time", timezone: "Europe/Berlin" },
{ label: "Western European Time", timezone: "Europe/London" },
{ label: "Australian Eastern Time", timezone: "Australia/Sydney" },
{ label: "Japan Standard Time", timezone: "Asia/Tokyo" },
{ label: "India Standard Time", timezone: "Asia/Kolkata" },
{ label: "Gulf Standard Time", timezone: "Asia/Dubai" },
{ label: "South Africa Standard Time", timezone: "Africa/Johannesburg" },
{ label: "Brazil Time", timezone: "America/Sao_Paulo" },
{ label: "Hawaii-Aleutian Standard Time", timezone: "Pacific/Honolulu" },
];
const option = {
@@ -104,34 +44,34 @@ describe("getTimezone", () => {
});
it("should return empty array for an empty string", () => {
expect(filterByCities("", cityData)).toMatchInlineSnapshot(`[]`);
expect(filterBySearchText("", timezonesFixture)).toMatchInlineSnapshot(`[]`);
});
it("should filter cities for a valid city name", () => {
expect(filterByCities("San Francisco", cityData)).toMatchInlineSnapshot(`
expect(filterBySearchText("San Francisco", timezonesFixture)).toMatchInlineSnapshot(`
[
{
"city": "San Francisco",
"label": "San Francisco",
"timezone": "America/Argentina/Cordoba",
},
{
"city": "San Francisco de Macoris",
"label": "San Francisco",
"timezone": "America/Los_Angeles",
},
{
"label": "San Francisco de Macoris",
"timezone": "America/Santo_Domingo",
},
{
"city": "San Francisco Gotera",
"label": "San Francisco Gotera",
"timezone": "America/El_Salvador",
},
{
"city": "San Francisco",
"timezone": "America/Los_Angeles",
},
]
`);
});
it("should return appropriate timezone(s) for a given city name array", () => {
expect(addCitiesToDropdown(cityData)).toMatchInlineSnapshot(`
expect(addTimezonesToDropdown(timezonesFixture)).toMatchInlineSnapshot(`
{
"Africa/Johannesburg": "South Africa Standard Time",
"America/Argentina/Cordoba": "San Francisco",
@@ -156,9 +96,9 @@ describe("getTimezone", () => {
});
it("should render city name as option label if cityData is not empty", () => {
expect(handleOptionLabel(option, cityData)).toMatchInlineSnapshot(`"San Francisco GMT -8:00"`);
expect(handleOptionLabel(option, timezonesFixture)).toMatchInlineSnapshot(`"San Francisco GMT -8:00"`);
vi.setSystemTime(new Date("2020-06-01"));
expect(handleOptionLabel(option, cityData)).toMatchInlineSnapshot(`"San Francisco GMT -7:00"`);
expect(handleOptionLabel(option, timezonesFixture)).toMatchInlineSnapshot(`"San Francisco GMT -7:00"`);
});
it("should return timezone as option label if cityData is empty", () => {
+2 -5
View File
@@ -14,11 +14,8 @@
}
],
"functions": {
"pages/api/trpc/public/[trpc].ts": {
"memory": 1024
},
"pages/api/trpc/slots/[trpc].ts": {
"memory": 1024
"pages/api/trpc/highPerf/[trpc].ts": {
"memory": 3008
},
"pages/api/trpc/appRoutingForms/[trpc].ts": {
"memory": 2048
@@ -16,7 +16,7 @@ Here is what you need to be able to run Cal.com.
- PostgreSQL
- Yarn (recommended)
Cal.com can be set up and run on your machine locally. Please take a look at [this quick guide](https://cal.com/documentation/getting-started/local-development) for instructions on setting it up.
Cal.com can be set up and run on your machine locally. Please take a look at [this quick guide](https://cal.com/docs/developing/local-development) for instructions on setting it up.
<iframe
@@ -19,6 +19,7 @@ import cron_config_json from "./cron/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
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 eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json";
@@ -117,6 +118,7 @@ export const appStoreMetadata = {
dailyvideo: dailyvideo__metadata_ts,
deel: deel_config_json,
demodesk: demodesk_config_json,
dialpad: dialpad_config_json,
discord: discord_config_json,
eightxeight: eightxeight_config_json,
"element-call": element_call_config_json,
@@ -19,6 +19,7 @@ export const apiHandlers = {
cron: import("./cron/api"),
deel: import("./deel/api"),
demodesk: import("./demodesk/api"),
dialpad: import("./dialpad/api"),
discord: import("./discord/api"),
eightxeight: import("./eightxeight/api"),
"element-call": import("./element-call/api"),
@@ -7,6 +7,7 @@ import campfire_config_json from "./campfire/config.json";
import campsite_config_json from "./campsite/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
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 eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json";
@@ -52,6 +53,7 @@ export const appStoreMetadata = {
campsite: campsite_config_json,
dailyvideo: dailyvideo__metadata_ts,
demodesk: demodesk_config_json,
dialpad: dialpad_config_json,
discord: discord_config_json,
eightxeight: eightxeight_config_json,
"element-call": element_call_config_json,
@@ -0,0 +1,8 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
---
{DESCRIPTION}
+16
View File
@@ -0,0 +1,16 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1
View File
@@ -0,0 +1 @@
export { default as add } from "./add";
+27
View File
@@ -0,0 +1,27 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "dialpad",
"slug": "dialpad",
"type": "dialpad_conferencing",
"logo": "icon.svg",
"url": "https://meetings.dialpad.com/",
"variant": "conferencing",
"categories": [
"conferencing"
],
"publisher": "Cal.com",
"email": "help@cal.com",
"appData": {
"location": {
"type": "integrations:{SLUG}_video",
"label": "{TITLE}",
"linkType": "static",
"organizerInputPlaceholder": "https://meetings.dialpad.com/adb2002",
"urlRegExp": "^https:\\/\\/meetings\\.dialpad\\.com\\/[a-zA-Z0-9]+$"
}
},
"description": "A new way to meet, with built-in Ai",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static"
}
+1
View File
@@ -0,0 +1 @@
export * as api from "./api";
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/dialpad",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "A new way to meet, with built-in Ai"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1014 719" style="enable-background:new 0 0 1014 719;" xml:space="preserve">
<style type="text/css">
.st0{fill:#6C3DFF;}
.st1{fill:none;}
</style>
<path class="st0" d="M682.3,180.4c-42.6,0-83.7,15.7-115.6,44c-32.2,28.6-53.1,67.9-58.9,110.5V88c0-1.1-0.6-2.1-1.7-2.5
c-1-0.4-2.2-0.2-2.9,0.6l-103,108.3c-21.7-9.3-45-14.1-68.6-14.1c-97.3,0-176.2,80.2-176.2,179.1s78.9,179.2,176.2,179.2
c42.6,0,83.7-15.7,115.6-44c32.2-28.6,53.1-67.9,58.9-110.5V631c0,1.5,1.1,2.7,2.6,2.7c0.7,0,1.5-0.3,2-0.8l103.1-108.3
c21.7,9.3,45,14.1,68.6,14.1c97.3,0,176.2-80.2,176.2-179.2S779.6,180.4,682.3,180.4z"/>
<rect x="114" y="58" class="st1" width="786" height="603"/>
</svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -42,11 +42,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.tokens;
const key = token.tokens;
const grantedScopes = token.tokens.scope?.split(" ") ?? [];
// Check if we have granted all required permissions
const hasMissingRequiredScopes = REQUIRED_SCOPES.some((scope) => !grantedScopes.includes(scope));
@@ -75,7 +73,12 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
});
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
let primaryCal = cals.data.items?.find((cal) => cal.primary);
const tokenInfo = key.access_token ? await oAuth2Client.getTokenInfo(key.access_token) : null;
const userEmail = tokenInfo?.email ?? null;
let primaryCal = cals.data.items?.find(
(cal) => cal.primary || cal.accessRole === "owner" || cal.id === userEmail
);
if (!primaryCal?.id) {
// If the primary calendar is not set, set it to the first calendar
primaryCal = cals.data.items?.[0];
@@ -15,6 +15,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: ["John"],
valueSrc: ["value"],
},
},
},
@@ -48,6 +49,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: [],
valueSrc: ["value"],
},
},
},
@@ -84,6 +86,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: [],
valueSrc: ["value"],
},
},
},
@@ -110,6 +113,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: [[]],
valueSrc: ["value"],
},
},
},
@@ -136,6 +140,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: [undefined, undefined],
valueSrc: ["value"],
},
},
},
@@ -162,6 +167,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: [null],
valueSrc: ["value"],
},
},
},
@@ -183,6 +189,7 @@ describe("queryValueValidationSchema", () => {
field: "name",
operator: "equal",
value: [""],
valueSrc: ["value"],
},
},
},
@@ -209,4 +216,42 @@ describe("queryValueValidationSchema", () => {
const result2 = queryValueSaveValidationSchema.safeParse(undefined);
expect(result2.success).toBe(true);
});
it("should allow fields with no value if valueSrc is empty", () => {
const result = queryValueSaveValidationSchema.safeParse({
id: "7",
type: "group",
children1: {
rule1: {
type: "rule",
properties: {
field: "name",
operator: "is_empty",
value: [],
valueSrc: [],
},
},
},
});
expect(result.success).toBe(true);
});
it("should not allow fields with no value if valueSrc is not empty", () => {
const result = queryValueSaveValidationSchema.safeParse({
id: "7",
type: "group",
children1: {
rule1: {
type: "rule",
properties: {
field: "name",
operator: "is_empty",
value: [],
valueSrc: ["value"],
},
},
},
});
expect(result.success).toBe(false);
});
});
@@ -323,11 +323,16 @@ test.describe("Routing Forms", () => {
"Multi Select",
"Legacy Select",
"Select",
// TODO: Find a way to incorporate Routed To and Booked At into the report
// @see https://github.com/calcom/cal.com/pull/17229
"Routed To",
"Booked At",
]);
/* Last two columns are "Routed To" and "Booked At" */
expect(responses).toEqual([
["event-routing", "Option-2", "Option-2", "Option-2", "Option-2"],
["external-redirect", "Option-2", "Option-2", "Option-2", "Option-2"],
["custom-page", "Option-2", "Option-2", "Option-2", "Option-2"],
["event-routing", "Option-2", "Option-2", "Option-2", "Option-2", "", ""],
["external-redirect", "Option-2", "Option-2", "Option-2", "Option-2", "", ""],
["custom-page", "Option-2", "Option-2", "Option-2", "Option-2", "", ""],
]);
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
@@ -4,6 +4,7 @@ import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { getSerializableForm } from "../lib/getSerializableForm";
@@ -15,11 +16,23 @@ import type { TReportInputSchema } from "./report.schema";
interface ReportHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TReportInputSchema;
}
export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
const makeFormatDate = (locale: string, timeZone: string) => {
const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(date);
};
return formatDate;
};
const getRows = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
@@ -32,6 +45,32 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
input.jsonLogicQuery
)}`
);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
include: {
routedToBooking: {
select: {
createdAt: true,
user: {
select: { id: true, name: true, email: true },
},
},
},
},
take,
skip,
});
return { skip, take, rows };
};
export const reportHandler = async (options: ReportHandlerOptions) => {
const {
ctx: { prisma },
input,
} = options;
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
@@ -46,15 +85,7 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
take,
skip,
});
const { rows, skip, take } = await getRows(options);
const fields = serializedForm?.fields || [];
const { responses, headers } = buildResponsesForReporting({
@@ -63,13 +94,43 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
return presenter({
rows,
options,
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
});
};
/**
* This is a temporary solution to make the report work. It should be incorporated into the report data itself.
* Right now we cannot filter by Routed To and Booked At because they are not part of the response data.
*/
function presenter(args: {
rows: Awaited<ReturnType<typeof getRows>>["rows"];
options: ReportHandlerOptions;
headers: string[];
responses: string[][];
nextCursor: number | null;
}) {
const { headers, responses, nextCursor, options, rows } = args;
const { ctx } = options;
const formatDate = makeFormatDate(ctx.user.locale, ctx.user.timeZone);
return {
nextCursor,
headers: [...headers, "Routed To", "Booked At"],
responses: responses.map((r, i) => {
const currentRow = rows[i];
return [
...r,
currentRow.routedToBooking?.user?.email || "",
currentRow.routedToBooking?.createdAt ? formatDate(currentRow.routedToBooking.createdAt) : "",
];
}),
};
}
export default reportHandler;
export function buildResponsesForReporting({
+9 -1
View File
@@ -64,6 +64,7 @@ export enum RouteActionType {
export const routeActionTypeSchema = z.nativeEnum(RouteActionType);
/**
* Stricter schema for validating before saving to DB
* It doesn't decide what will be saved, it is just to validate the data before saving
*/
export const queryValueSaveValidationSchema = queryValueSchema
.omit({ children1: true })
@@ -78,6 +79,7 @@ export const queryValueSaveValidationSchema = queryValueSchema
field: z.any().optional(),
operator: z.any().optional(),
value: z.any().optional(),
valueSrc: z.any().optional(),
})
.optional(),
})
@@ -94,7 +96,13 @@ export const queryValueSaveValidationSchema = queryValueSchema
if (!isObject(rule.properties)) return;
const value = rule.properties.value || [];
if (!(value instanceof Array)) {
const valueSrc = rule.properties.valueSrc;
if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
return;
}
if (!valueSrc.length) {
// If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
return;
}
@@ -8,7 +8,7 @@ import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Switch, Alert, Select } from "@calcom/ui";
import { Switch, Alert, Select, Button, InputField, showToast } from "@calcom/ui";
import { SalesforceRecordEnum } from "../lib/recordEnum";
import type { appDataSchema } from "../zod";
@@ -25,6 +25,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const createLeadIfAccountNull = getAppData("createLeadIfAccountNull");
const createNewContactUnderAccount = getAppData("createNewContactUnderAccount");
const createEventOn = getAppData("createEventOn") ?? SalesforceRecordEnum.CONTACT;
const onBookingWriteToEventObject = getAppData("onBookingWriteToEventObject") ?? false;
const onBookingWriteToEventObjectMap = getAppData("onBookingWriteToEventObjectMap") ?? {};
const { t } = useLocale();
const recordOptions = [
@@ -45,6 +47,11 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
checkOwnerOptions.find((option) => option.value === roundRobinSkipCheckRecordOn) ?? checkOwnerOptions[0]
);
const [newOnBookingWriteToEventObjectField, setNewOnBookingWriteToEventObjectField] = useState({
field: "",
value: "",
});
return (
<AppCard
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
@@ -109,6 +116,98 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
</>
) : null}
<div className="mt-4">
<Switch
label={t("on_booking_write_to_event_object")}
labelOnLeading
checked={onBookingWriteToEventObject}
onCheckedChange={(checked) => {
setAppData("onBookingWriteToEventObject", checked);
}}
/>
{onBookingWriteToEventObject ? (
<div className="ml-2 mt-2">
<div className="grid grid-cols-3 gap-4">
<div>{t("field_name")}</div>
<div>{t("value")}</div>
</div>
<div>
{...Object.keys(onBookingWriteToEventObjectMap).map((key) => (
<div className="mt-2 grid grid-cols-3 gap-4" key={key}>
<div>
<InputField value={key} readOnly />
</div>
<div>
<InputField value={onBookingWriteToEventObjectMap[key]} readOnly />
</div>
<div>
<Button
StartIcon="trash"
variant="icon"
color="destructive"
onClick={() => {
const newObject = onBookingWriteToEventObjectMap;
delete onBookingWriteToEventObjectMap[key];
setAppData("onBookingWriteToEventObjectMap", newObject);
}}
/>
</div>
</div>
))}
<div className="mt-2 grid grid-cols-3 gap-4">
<div>
<InputField
value={newOnBookingWriteToEventObjectField.field}
onChange={(e) =>
setNewOnBookingWriteToEventObjectField({
...newOnBookingWriteToEventObjectField,
field: e.target.value,
})
}
/>
</div>
<div>
<InputField
value={newOnBookingWriteToEventObjectField.value}
onChange={(e) =>
setNewOnBookingWriteToEventObjectField({
...newOnBookingWriteToEventObjectField,
value: e.target.value,
})
}
/>
</div>
</div>
</div>
<Button
className="mt-2"
size="sm"
disabled={
!(newOnBookingWriteToEventObjectField.field && newOnBookingWriteToEventObjectField.value)
}
onClick={() => {
if (
Object.keys(onBookingWriteToEventObjectMap).includes(
newOnBookingWriteToEventObjectField.field.trim()
)
) {
showToast("Field already exists", "error");
return;
}
setAppData("onBookingWriteToEventObjectMap", {
...onBookingWriteToEventObjectMap,
[newOnBookingWriteToEventObjectField.field.trim()]:
newOnBookingWriteToEventObjectField.value.trim(),
});
setNewOnBookingWriteToEventObjectField({ field: "", value: "" });
}}>
{t("add_new_field")}
</Button>
</div>
) : null}
</div>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN ? (
<div className="mt-4">
<Switch
+89 -19
View File
@@ -171,8 +171,26 @@ export default class SalesforceCRMService implements CRM {
};
private salesforceCreateEvent = async (event: CalendarEvent, contacts: Contact[]) => {
const appOptions = this.getAppOptions();
const customFieldInputsEnabled =
appOptions?.onBookingWriteToEventObject && appOptions?.onBookingWriteToEventObjectMap;
const customFieldInputs = customFieldInputsEnabled
? await this.ensureFieldsExistOnObject(Object.keys(appOptions?.onBookingWriteToEventObjectMap), "Event")
: [];
const confirmedCustomFieldInputs: {
[key: string]: any;
} = {};
for (const field of customFieldInputs) {
confirmedCustomFieldInputs[field] = appOptions.onBookingWriteToEventObjectMap[field];
}
const createdEvent = await this.salesforceCreateEventApiCall(event, {
EventWhoIds: contacts.map((contact) => contact.id),
...confirmedCustomFieldInputs,
}).catch(async (reason) => {
if (reason === sfApiErrors.INVALID_EVENTWHOIDS) {
this.calWarnings.push(
@@ -388,6 +406,8 @@ export default class SalesforceCRMService implements CRM {
const accountId = this.getDominantAccountId(response.records as { AccountId: string }[]);
let contactCreated = false;
if (accountId && appOptions.createNewContactUnderAccount) {
// First see if the contact already exists and connect it to the account
const userQuery = await conn.query(`SELECT Id, Email FROM Contact WHERE Email = '${attendee.email}'`);
@@ -414,35 +434,60 @@ export default class SalesforceCRMService implements CRM {
.then((result) => {
if (result.success) {
createdContacts.push({ id: result.id, email: attendee.email });
contactCreated = true;
}
});
}
if (appOptions.createLeadIfAccountNull) {
await Promise.all(
contactsToCreate.map(async (attendee) => {
return await conn
.sobject(SalesforceRecordEnum.LEAD)
.create(
this.generateCreateRecordBody({
attendee,
recordType: SalesforceRecordEnum.LEAD,
organizerId,
})
)
.then((result) => {
if (result.success) {
createdContacts.push({ id: result.id, email: attendee.email });
}
});
})
);
if (!accountId && appOptions.createLeadIfAccountNull && !contactCreated) {
// Check to see if the lead exists already
const leadQuery = await conn.query(`SELECT Id, Email FROM Lead WHERE Email = '${attendee.email}'`);
if (leadQuery.records.length) {
const contact = leadQuery.records[0] as { Id: string; Email: string };
return [{ id: contact.Id, email: contact.Email }];
}
for (const attendee of contactsToCreate) {
try {
const result = await conn.sobject(SalesforceRecordEnum.LEAD).create(
this.generateCreateRecordBody({
attendee,
recordType: SalesforceRecordEnum.LEAD,
organizerId,
})
);
if (result.success) {
createdContacts.push({ id: result.id, email: attendee.email });
}
} catch (error: any) {
if (error.name === "DUPLICATES_DETECTED") {
const existingId = this.getExistingIdFromDuplicateError(error);
if (existingId) {
console.log("Using existing record:", existingId);
createdContacts.push({ id: existingId, email: attendee.email });
}
} else {
console.error("Error creating lead:", error);
}
}
}
}
}
return createdContacts;
}
private getExistingIdFromDuplicateError(error: any): string | null {
if (error.duplicateResult && error.duplicateResult.matchResults) {
for (const matchResult of error.duplicateResult.matchResults) {
if (matchResult.matchRecords && matchResult.matchRecords.length > 0) {
return matchResult.matchRecords[0].record.Id;
}
}
}
return null;
}
private setDoNotCreateEvent(boolean: boolean) {
this.doNotCreateEvent = boolean;
}
@@ -500,4 +545,29 @@ export default class SalesforceCRMService implements CRM {
...(recordType === SalesforceRecordEnum.LEAD && { Company: company }),
};
}
private async ensureFieldsExistOnObject(fieldsToTest: string[], sobject: string) {
const conn = await this.conn;
const fieldSet = new Set(fieldsToTest);
const foundFields: string[] = [];
try {
const salesforceEntity = await conn.describe(sobject);
const fields = salesforceEntity.fields;
for (const field of fields) {
if (foundFields.length === fieldSet.size) break;
if (fieldSet.has(field.name)) {
foundFields.push(field.name);
}
}
return foundFields;
} catch (e) {
console.error(e);
return [];
}
}
}
+2
View File
@@ -13,6 +13,8 @@ export const appDataSchema = eventTypeAppCardZod.extend({
createEventOn: z.nativeEnum(SalesforceRecordEnum).default(SalesforceRecordEnum.CONTACT).optional(),
createNewContactUnderAccount: z.boolean().optional(),
createLeadIfAccountNull: z.boolean().optional(),
onBookingWriteToEventObject: z.boolean().optional(),
onBookingWriteToEventObjectMap: z.record(z.any()).optional(),
});
export const appKeysSchema = z.object({
+1 -1
View File
@@ -976,7 +976,7 @@ export default class EventManager {
let success = true;
const createdEvent = await crm.createEvent(event, currentAppOption).catch((error) => {
success = false;
log.warn(`Error creating crm event for ${credential.type}`, error);
log.warn(`Error creating crm event for ${credential.type}`, JSON.stringify(error));
});
if (createdEvent) {
+96 -93
View File
@@ -1,4 +1,11 @@
import type { Booking, Prisma, EventType as PrismaEventType } from "@prisma/client";
import type {
Booking,
Prisma,
OutOfOfficeEntry,
OutOfOfficeReason,
User,
EventType as PrismaEventType,
} from "@prisma/client";
import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
@@ -152,7 +159,7 @@ const _getUser = async (where: Prisma.UserWhereInput) => {
});
};
type User = Awaited<ReturnType<typeof getUser>>;
type GetUser = Awaited<ReturnType<typeof getUser>>;
export const getCurrentSeats = async (
...args: Parameters<typeof _getCurrentSeats>
@@ -238,7 +245,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
returnDateOverrides: boolean;
},
initialData?: {
user?: User;
user?: GetUser;
eventType?: EventType;
currentSeats?: CurrentSeats;
rescheduleUid?: string | null;
@@ -251,6 +258,11 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
seatsReferences: number;
};
})[];
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end"> & {
user: Pick<User, "id" | "name">;
toUser: Pick<User, "id" | "username" | "name"> | null;
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
})[];
busyTimesFromLimitsBookings: EventBusyDetails[];
}
) {
@@ -297,10 +309,10 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
)[0];
const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule;
// TODO: It uses default timezone of user. Should we use timezone of team ?
const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone;
const fallbackSchedule = {
availability: [
{
@@ -312,10 +324,12 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
],
id: 0,
timeZone: fallbackTimezoneIfScheduleIsMissing
timeZone: fallbackTimezoneIfScheduleIsMissing,
};
const schedule = (eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule) ?? fallbackSchedule
const schedule =
(eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule) ??
fallbackSchedule;
const timeZone = schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing;
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
@@ -445,12 +459,74 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
}
}
const datesOutOfOffice = await getOutOfOfficeDays({
userId: user.id,
dateFrom,
dateTo,
availability,
});
const outOfOfficeDays =
initialData?.outOfOfficeDays ??
(await prisma.outOfOfficeEntry.findMany({
where: {
userId: user.id,
OR: [
// outside of range
// (start <= 'dateTo' AND end >= 'dateFrom')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateFrom.toISOString(),
},
},
// start is between dateFrom and dateTo but end is outside of range
// (start <= 'dateTo' AND end >= 'dateTo')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateTo.toISOString(),
},
},
// end is between dateFrom and dateTo but start is outside of range
// (start <= 'dateFrom' OR end <= 'dateTo')
{
start: {
lte: dateFrom.toISOString(),
},
end: {
lte: dateTo.toISOString(),
},
},
],
},
select: {
id: true,
start: true,
end: true,
user: {
select: {
id: true,
name: true,
},
},
toUser: {
select: {
id: true,
username: true,
name: true,
},
},
reason: {
select: {
id: true,
emoji: true,
reason: true,
},
},
},
}));
const datesOutOfOffice: IOutOfOfficeData = calculateOutOfOfficeRanges(outOfOfficeDays, availability);
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
dateFrom,
@@ -520,9 +596,6 @@ const _getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: Int
};
interface GetUserAvailabilityParamsDTO {
userId: number;
dateFrom: Dayjs;
dateTo: Dayjs;
availability: (DateOverride | WorkingHours)[];
}
@@ -546,83 +619,11 @@ export interface IOutOfOfficeData {
};
}
const getOutOfOfficeDays = async (
...args: Parameters<typeof _getOutOfOfficeDays>
): Promise<ReturnType<typeof _getOutOfOfficeDays>> => {
return monitorCallbackAsync(_getOutOfOfficeDays, ...args);
};
const _getOutOfOfficeDays = async ({
userId,
dateFrom,
dateTo,
availability,
}: GetUserAvailabilityParamsDTO): Promise<IOutOfOfficeData> => {
const outOfOfficeDays = await prisma.outOfOfficeEntry.findMany({
where: {
userId,
OR: [
// outside of range
// (start <= 'dateTo' AND end >= 'dateFrom')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateFrom.toISOString(),
},
},
// start is between dateFrom and dateTo but end is outside of range
// (start <= 'dateTo' AND end >= 'dateTo')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateTo.toISOString(),
},
},
// end is between dateFrom and dateTo but start is outside of range
// (start <= 'dateFrom' OR end <= 'dateTo')
{
start: {
lte: dateFrom.toISOString(),
},
end: {
lte: dateTo.toISOString(),
},
},
],
},
select: {
id: true,
start: true,
end: true,
user: {
select: {
id: true,
name: true,
},
},
toUser: {
select: {
id: true,
username: true,
name: true,
},
},
reason: {
select: {
id: true,
emoji: true,
reason: true,
},
},
},
});
if (!outOfOfficeDays.length) {
const calculateOutOfOfficeRanges = (
outOfOfficeDays: GetUserAvailabilityInitialData["outOfOfficeDays"],
availability: GetUserAvailabilityParamsDTO["availability"]
): IOutOfOfficeData => {
if (!outOfOfficeDays || outOfOfficeDays.length === 0) {
return {};
}
@@ -673,6 +674,7 @@ const _getUsersAvailability = async ({
}: {
users: (GetAvailabilityUser & {
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
outOfOfficeDays?: GetUserAvailabilityInitialData["outOfOfficeDays"];
})[];
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
initialData?: Omit<GetUserAvailabilityInitialData, "user">;
@@ -690,6 +692,7 @@ const _getUsersAvailability = async ({
...initialData,
user,
currentBookings: user.currentBookings,
outOfOfficeDays: user.outOfOfficeDays,
}
: undefined
)
+13 -4
View File
@@ -65,6 +65,7 @@ import OrganizerDailyVideoDownloadRecordingEmail from "./templates/organizer-dai
import OrganizerDailyVideoDownloadTranscriptEmail from "./templates/organizer-daily-video-download-transcript-email";
import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email";
import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
import OrganizerReassignedEmail from "./templates/organizer-reassigned-email";
import OrganizerRequestEmail from "./templates/organizer-request-email";
import OrganizerRequestReminderEmail from "./templates/organizer-request-reminder-email";
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
@@ -191,7 +192,8 @@ export const sendRoundRobinRescheduledEmailsAndSMS = async (
export const sendRoundRobinCancelledEmailsAndSMS = async (
calEvent: CalendarEvent,
members: Person[],
eventTypeMetadata?: EventTypeMetadata
eventTypeMetadata?: EventTypeMetadata,
reassignedTo?: { name: string | null; email: string }
) => {
if (eventTypeDisableHostEmail(eventTypeMetadata)) return;
const calendarEvent = formatCalEvent(calEvent);
@@ -199,9 +201,16 @@ export const sendRoundRobinCancelledEmailsAndSMS = async (
const successfullyReScheduledSMS = new EventCancelledSMS(calEvent);
for (const teamMember of members) {
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember }))
);
if (!reassignedTo) {
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember }))
);
} else {
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerReassignedEmail({ calEvent: calendarEvent, teamMember, reassignedTo }))
);
}
if (teamMember.phoneNumber) {
emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(teamMember));
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
const PersonInfo = ({ name = "", email = "", role = "", phoneNumber = "" }) => (
export const PersonInfo = ({ name = "", email = "", role = "", phoneNumber = "" }) => (
<div style={{ color: "#101010", fontWeight: 400, lineHeight: "24px" }}>
{name} - {role} {phoneNumber}
{!isSmsCalEmail(email) && (
@@ -15,6 +15,7 @@ import {
AppsStatus,
UserFieldsResponses,
} from "../components";
import { PersonInfo } from "../components/WhoInfo";
export const BaseScheduledEmail = (
props: {
@@ -26,6 +27,7 @@ export const BaseScheduledEmail = (
locale: string;
timeFormat: TimeFormat | undefined;
isOrganizer?: boolean;
reassignedTo?: { name: string | null; email: string };
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { t, timeZone, locale, timeFormat: timeFormat_ } = props;
@@ -79,7 +81,15 @@ export const BaseScheduledEmail = (
withSpacer
/>
)}
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
{props.reassignedTo && (
<Info
label={t("reassigned_to")}
description={
<PersonInfo name={props.reassignedTo.name || undefined} email={props.reassignedTo.email} />
}
withSpacer
/>
)}
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo timeFormat={timeFormat} calEvent={props.calEvent} t={t} timeZone={timeZone} locale={locale} />
<WhoInfo calEvent={props.calEvent} t={t} />
@@ -0,0 +1,16 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerReassignedEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate;
return (
<OrganizerScheduledEmail
title="event_request_reassigned"
headerType="xCircle"
subject="event_reassigned_subject"
subtitle={<>{t("event_reassigned_subtitle")}</>}
callToAction={null}
reassignedTo={props.reassignedTo}
{...props}
/>
);
};
@@ -10,6 +10,7 @@ export const OrganizerScheduledEmail = (
newSeat?: boolean;
attendeeCancelled?: boolean;
teamMember?: Person;
reassignedTo?: { email: string; name: string | null };
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
let subject;
@@ -49,12 +50,17 @@ export const OrganizerScheduledEmail = (
timeFormat={timeFormat}
isOrganizer
subtitle={
<>
{props.attendeeCancelled
? t("attendee_no_longer_attending_subtitle", { name: props.attendee.name })
: ""}
</>
props.subtitle ? (
props.subtitle
) : (
<>
{props.attendeeCancelled
? t("attendee_no_longer_attending_subtitle", { name: props.attendee.name })
: ""}
</>
)
}
reassignedTo={props.reassignedTo}
{...props}
attendee={attendee}
/>
+1
View File
@@ -12,6 +12,7 @@ export { SlugReplacementEmail } from "./SlugReplacementEmail";
export { FeedbackEmail } from "./FeedbackEmail";
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";
export { OrganizerReassignedEmail } from "./OrganizerReassignedEmail";
export { OrganizerLocationChangeEmail } from "./OrganizerLocationChangeEmail";
export { OrganizerPaymentRefundFailedEmail } from "./OrganizerPaymentRefundFailedEmail";
export { OrganizerRequestEmail } from "./OrganizerRequestEmail";
@@ -0,0 +1,31 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerReassignedEmail extends OrganizerScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: generateIcsFile({
calEvent: this.calEvent,
status: "CANCELLED",
role: GenerateIcsRole.ORGANIZER,
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("event_reassigned_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("OrganizerReassignedEmail", {
attendee: this.calEvent.organizer,
calEvent: this.calEvent,
reassignedTo: this.reassignedTo,
}),
text: this.getTextBody("event_request_reassigned"),
};
}
}
@@ -17,14 +17,21 @@ export default class OrganizerScheduledEmail extends BaseEmail {
t: TFunction;
newSeat?: boolean;
teamMember?: Person;
reassignedTo?: { name: string | null; email: string };
constructor(input: { calEvent: CalendarEvent; newSeat?: boolean; teamMember?: Person }) {
constructor(input: {
calEvent: CalendarEvent;
newSeat?: boolean;
teamMember?: Person;
reassignedTo?: { email: string; name: string | null };
}) {
super();
this.name = "SEND_BOOKING_CONFIRMATION";
this.calEvent = input.calEvent;
this.t = this.calEvent.organizer.language.translate;
this.newSeat = input.newSeat;
this.teamMember = input.teamMember;
this.reassignedTo = input.reassignedTo;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
@@ -1,64 +0,0 @@
{
"timezones": [
{
"name": "Eastern Time - US & Canada",
"identifier": "America/New_York"
},
{
"name": "Pacific Time - US & Canada",
"identifier": "America/Los_Angeles"
},
{
"name": "Central Time - US & Canada",
"identifier": "America/Chicago"
},
{
"name": "Mountain Time - US & Canada",
"identifier": "America/Denver"
},
{
"name": "Atlantic Time - Canada",
"identifier": "America/Halifax"
},
{
"name": "Eastern European Time",
"identifier": "Europe/Bucharest"
},
{
"name": "Central European Time",
"identifier": "Europe/Berlin"
},
{
"name": "Western European Time",
"identifier": "Europe/London"
},
{
"name": "Australian Eastern Time",
"identifier": "Australia/Sydney"
},
{
"name": "Japan Standard Time",
"identifier": "Asia/Tokyo"
},
{
"name": "India Standard Time",
"identifier": "Asia/Kolkata"
},
{
"name": "Gulf Standard Time",
"identifier": "Asia/Dubai"
},
{
"name": "South Africa Standard Time",
"identifier": "Africa/Johannesburg"
},
{
"name": "Brazil Time",
"identifier": "America/Sao_Paulo"
},
{
"name": "Hawaii-Aleutian Standard Time",
"identifier": "Pacific/Honolulu"
}
]
}
@@ -1,10 +0,0 @@
import * as additionalTimezones from "./additionalTimezones.json";
export interface Timezone {
name: string;
identifier: string;
}
export const timezones: Timezone[] = additionalTimezones.timezones;
export default additionalTimezones;
@@ -179,17 +179,32 @@ const LayoutToggle = ({
return [
{
value: BookerLayouts.MONTH_VIEW,
label: <Icon name="calendar" width="16" height="16" />,
label: (
<>
<Icon name="calendar" width="16" height="16" />
<span className="sr-only">${t("switch_monthly")}</span>
</>
),
tooltip: t("switch_monthly"),
},
{
value: BookerLayouts.WEEK_VIEW,
label: <Icon name="grid-3x3" width="16" height="16" />,
label: (
<>
<Icon name="grid-3x3" width="16" height="16" />
<span className="sr-only">${t("switch_weekly")}</span>
</>
),
tooltip: t("switch_weekly"),
},
{
value: BookerLayouts.COLUMN_VIEW,
label: <Icon name="columns-3" width="16" height="16" />,
label: (
<>
<Icon name="columns-3" width="16" height="16" />
<span className="sr-only">${t("switch_columnview")}</span>
</>
),
tooltip: t("switch_columnview"),
},
].filter((layout) => enabledLayouts?.includes(layout.value as BookerLayouts));
@@ -810,6 +810,7 @@ async function handler(
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId,
orgId,
oAuthClientId: platformClientId,
};
const subscriberOptionsMeetingStarted = {
@@ -818,6 +819,7 @@ async function handler(
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
teamId,
orgId,
oAuthClientId: platformClientId,
};
const workflows = await getAllWorkflowsFromEventType(eventType, organizerUser.id);
@@ -16,6 +16,7 @@ import {
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME } from "@calcom/lib/constants";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -244,7 +245,6 @@ export const roundRobinManualReassignment = async ({
bookingFields: eventType.bookingFields ?? null,
booking,
}),
cancellationReason: "Manually re-assigned",
};
const credentials = await prisma.credential.findMany({
@@ -287,7 +287,7 @@ export const roundRobinManualReassignment = async ({
},
]);
// Send cancellation email to original organizer
// Send cancellation email to previous RR host
const cancelledEvt = cloneDeep(evt);
cancelledEvt.organizer = {
email: originalOrganizer.email,
@@ -308,7 +308,8 @@ export const roundRobinManualReassignment = async ({
language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" },
},
],
eventType?.metadata as EventTypeMetadata
eventType?.metadata as EventTypeMetadata,
{ name: newUser.name, email: newUser.email }
);
}
@@ -347,8 +348,9 @@ async function handleWorkflowsUpdate({
where: {
bookingUid: booking.uid,
method: WorkflowMethods.EMAIL,
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
workflowStep: {
action: WorkflowActions.EMAIL_HOST,
workflow: {
trigger: {
in: [
@@ -365,6 +367,7 @@ async function handleWorkflowsUpdate({
referenceId: true,
workflowStep: {
select: {
id: true,
template: true,
workflow: {
select: {
@@ -373,6 +376,10 @@ async function handleWorkflowsUpdate({
timeUnit: true,
},
},
emailSubject: true,
reminderBody: true,
sender: true,
includeCalendarEvent: true,
},
},
},
@@ -382,10 +389,10 @@ async function handleWorkflowsUpdate({
const bookerUrl = await getBookerBaseUrl(orgId);
for (const workflowReminder of workflowReminders) {
const workflowStep = workflowReminder?.workflowStep;
const workflow = workflowStep?.workflow;
const workflowStep = workflowReminder.workflowStep;
if (workflowStep && workflow) {
if (workflowStep) {
const workflow = workflowStep.workflow;
await scheduleEmailReminder({
evt: {
...evt,
@@ -401,6 +408,12 @@ async function handleWorkflowsUpdate({
},
sendTo: newUser.email,
template: workflowStep.template,
emailSubject: workflowStep.emailSubject || undefined,
emailBody: workflowStep.reminderBody || undefined,
sender: workflowStep.sender || SENDER_NAME,
hideBranding: true,
includeCalendarEvent: workflowStep.includeCalendarEvent,
workflowStepId: workflowStep.id,
});
}
@@ -18,6 +18,7 @@ import {
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME } from "@calcom/lib/constants";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { getLuckyUser } from "@calcom/lib/server";
@@ -375,18 +376,20 @@ export const roundRobinReassignment = async ({
language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" },
},
],
eventType?.metadata as EventTypeMetadata
eventType?.metadata as EventTypeMetadata,
{ name: reassignedRRHost.name, email: reassignedRRHost.email }
);
}
// Handle changing workflows with organizer
if (hasOrganizerChanged) {
const workflowReminders = await prisma.workflowReminder.findMany({
const scheduledWorkflowReminders = await prisma.workflowReminder.findMany({
where: {
bookingUid: booking.uid,
method: WorkflowMethods.EMAIL,
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
workflowStep: {
action: WorkflowActions.EMAIL_HOST,
workflow: {
trigger: {
in: [
@@ -403,6 +406,7 @@ export const roundRobinReassignment = async ({
referenceId: true,
workflowStep: {
select: {
id: true,
template: true,
workflow: {
select: {
@@ -411,6 +415,10 @@ export const roundRobinReassignment = async ({
timeUnit: true,
},
},
emailSubject: true,
reminderBody: true,
sender: true,
includeCalendarEvent: true,
},
},
},
@@ -420,7 +428,7 @@ export const roundRobinReassignment = async ({
const bookerUrl = await getBookerBaseUrl(orgId);
for (const workflowReminder of workflowReminders) {
for (const workflowReminder of scheduledWorkflowReminders) {
const workflowStep = workflowReminder?.workflowStep;
const workflow = workflowStep?.workflow;
@@ -440,6 +448,12 @@ export const roundRobinReassignment = async ({
},
sendTo: reassignedRRHost.email,
template: workflowStep.template,
emailSubject: workflowStep.emailSubject || undefined,
emailBody: workflowStep.reminderBody || undefined,
sender: workflowStep.sender || SENDER_NAME,
hideBranding: true,
includeCalendarEvent: workflowStep.includeCalendarEvent,
workflowStepId: workflowStep.id,
});
}
+1 -1
View File
@@ -420,7 +420,7 @@ const EmailEmbedPreview = ({
selectedDateAndTime[key].map((time) => {
// If teamId is present on eventType and is not null, it means it is a team event.
// So we add 'team/' to the url.
const bookingURL = `${WEBSITE_URL}/${
const bookingURL = `${eventType.bookerUrl}/${
eventType.teamId !== null ? "team/" : ""
}${username}/${eventType.slug}?duration=${
eventType.length
@@ -306,8 +306,8 @@ export const Components: Record<FieldType, Component> = {
value.push("");
setValue(value);
}}
className="mr-auto">
{label}
className="mr-auto h-fit whitespace-normal text-left">
<span className="flex-1">{label}</span>
</Button>
)}
</>
@@ -1,5 +1,6 @@
import { Title } from "@tremor/react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -18,8 +19,8 @@ export const AverageEventDurationChart = () => {
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
startDate: dayjs.utc(startDate).toISOString(),
endDate: dayjs.utc(endDate).toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
@@ -1,5 +1,6 @@
import { Title } from "@tremor/react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -33,8 +34,8 @@ export const BookingStatusLineChart = () => {
} = trpc.viewer.insights.eventsTimeline.useQuery(
{
timeView: selectedTimeView,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
startDate: dayjs.utc(startDate).toISOString(),
endDate: dayjs.utc(endDate).toISOString(),
teamId: selectedTeamId ?? undefined,
eventTypeId: selectedEventTypeId ?? undefined,
userId: selectedUserId ?? undefined,
@@ -39,7 +39,7 @@ export const DateSelect = () => {
endDate = dayjs().endOf("day");
break;
case "w": // Last 7 days
startDate = dayjs().subtract(7, "day").startOf("day");
startDate = dayjs().subtract(1, "week").startOf("day");
endDate = dayjs().endOf("day");
break;
case "t": // Last 30 days
+220 -13
View File
@@ -1,13 +1,180 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { RawDataInput } from "./raw-data.schema";
type TimeViewType = "week" | "month" | "year" | "day";
type DateRange = {
startDate: string; // ISO string format
endDate: string; // ISO string format
formattedDate: string;
};
type StatusAggregate = {
completed: number;
rescheduled: number;
cancelled: number;
noShowHost: number;
_all: number;
uncompleted: number;
};
type AggregateResult = {
[date: string]: StatusAggregate;
};
// Recursive function to convert a JSON condition object into SQL
// Helper type guard function to check if value has 'in' property
function isInCondition(value: any): value is { in: any[] } {
return typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in);
}
// Helper type guard function to check if value has 'gte' property
function isGteCondition(value: any): value is { gte: any } {
return typeof value === "object" && value !== null && "gte" in value;
}
// Helper type guard function to check if value has 'lte' property
function isLteCondition(value: any): value is { lte: any } {
return typeof value === "object" && value !== null && "lte" in value;
}
function buildSqlCondition(condition: any): string {
if (Array.isArray(condition.OR)) {
return `(${condition.OR.map(buildSqlCondition).join(" OR ")})`;
} else if (Array.isArray(condition.AND)) {
return `(${condition.AND.map(buildSqlCondition).join(" AND ")})`;
} else {
const clauses: string[] = [];
for (const [key, value] of Object.entries(condition)) {
if (isInCondition(value)) {
const valuesList = value.in.map((v) => `'${v}'`).join(", ");
clauses.push(`"${key}" IN (${valuesList})`);
} else if (isGteCondition(value)) {
clauses.push(`"${key}" >= '${value.gte}'`);
} else if (isLteCondition(value)) {
clauses.push(`"${key}" <= '${value.lte}'`);
} else {
const formattedValue = typeof value === "string" ? `'${value}'` : value;
clauses.push(`"${key}" = ${formattedValue}`);
}
}
return clauses.join(" AND ");
}
}
class EventsInsights {
static countGroupedByStatusForRanges = async (
whereConditional: Prisma.BookingTimeStatusWhereInput,
startDate: Dayjs,
endDate: Dayjs,
timeView: "week" | "month" | "year" | "day"
): Promise<AggregateResult> => {
// Determine the date truncation and date range based on timeView
const formattedStartDate = dayjs(startDate).format("YYYY-MM-DD HH:mm:ss");
const formattedEndDate = dayjs(endDate).format("YYYY-MM-DD HH:mm:ss");
const whereClause = buildSqlCondition(whereConditional);
const data = await prisma.$queryRaw<
{ periodStart: Date; bookingsCount: number; timeStatus: string; noShowHost: boolean }[]
>`
SELECT
"periodStart",
CAST(COUNT(*) AS INTEGER) AS "bookingsCount",
"timeStatus",
"noShowHost"
FROM (
SELECT
DATE_TRUNC(${timeView}, "createdAt") AS "periodStart",
"timeStatus",
"noShowHost"
FROM
"BookingTimeStatus"
WHERE
"createdAt" BETWEEN ${formattedStartDate}::timestamp AND ${formattedEndDate}::timestamp
AND ${Prisma.raw(whereClause)}
) AS truncated_dates
GROUP BY
"periodStart",
"timeStatus",
"noShowHost"
ORDER BY
"periodStart";
`;
const aggregate: AggregateResult = {};
data.forEach(({ periodStart, bookingsCount, timeStatus, noShowHost }) => {
const formattedDate = dayjs(periodStart).format("MMM D, YYYY");
if (dayjs(periodStart).isAfter(endDate)) {
return;
}
// Ensure the date entry exists in the aggregate object
if (!aggregate[formattedDate]) {
aggregate[formattedDate] = {
completed: 0,
rescheduled: 0,
cancelled: 0,
noShowHost: 0,
_all: 0,
uncompleted: 0,
};
}
// Add to the specific status count
const statusKey = timeStatus as keyof StatusAggregate;
aggregate[formattedDate][statusKey] += Number(bookingsCount);
// Always add to the total count (_all)
aggregate[formattedDate]["_all"] += Number(bookingsCount);
// Track no-show host counts separately
if (noShowHost) {
aggregate[formattedDate]["noShowHost"] += Number(bookingsCount);
}
});
// Generate a complete list of expected date labels based on the timeline
let current = dayjs(startDate);
const expectedDates: string[] = [];
while (current.isBefore(endDate) || current.isSame(endDate)) {
const formattedDate = current.format("MMM D, YYYY");
expectedDates.push(formattedDate);
// Increment based on the selected timeView
if (timeView === "day") {
current = current.add(1, "day");
} else if (timeView === "week") {
current = current.add(1, "week");
} else if (timeView === "month") {
current = current.add(1, "month");
} else if (timeView === "year") {
current = current.add(1, "year");
}
}
// Fill in any missing dates with zero counts
expectedDates.forEach((label) => {
if (!aggregate[label]) {
aggregate[label] = {
completed: 0,
rescheduled: 0,
cancelled: 0,
noShowHost: 0,
_all: 0,
uncompleted: 0,
};
}
});
return aggregate;
};
static countGroupedByStatus = async (where: Prisma.BookingTimeStatusWhereInput) => {
const data = await prisma.bookingTimeStatus.groupBy({
where,
@@ -122,24 +289,23 @@ class EventsInsights {
}
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs): string[] {
const now = dayjs();
const endOfDay = now.endOf("day");
let pivotDate = dayjs(startDate);
let pivotDate = dayjs(endDate);
const dates: string[] = [];
while (pivotDate.isBefore(endDate) || pivotDate.isSame(endDate)) {
const pivotAdded = pivotDate.add(6, "day");
const weekEndDate = pivotAdded.isBefore(endOfDay) ? pivotAdded : endOfDay;
dates.push(pivotDate.format("YYYY-MM-DD"));
// Add the endDate as the last date in the timeline
dates.push(pivotDate.format("YYYY-MM-DD"));
if (pivotDate.isSame(endDate)) {
// Move backwards in 6-day increments until reaching or passing the startDate
while (pivotDate.isAfter(startDate)) {
pivotDate = pivotDate.subtract(7, "day");
if (pivotDate.isBefore(startDate)) {
break;
}
pivotDate = weekEndDate.add(1, "day");
dates.push(pivotDate.format("YYYY-MM-DD"));
}
return dates;
// Reverse the array to have the timeline in ascending order
return dates.reverse();
}
static getMonthTimeline(startDate: Dayjs, endDate: Dayjs) {
@@ -207,7 +373,48 @@ class EventsInsights {
where: whereConditional,
});
return csvData;
const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string);
if (uids.length === 0) {
return csvData;
}
const bookings = await prisma.booking.findMany({
where: {
uid: {
in: uids,
},
},
select: {
uid: true,
attendees: {
select: {
name: true,
email: true,
},
},
},
});
const bookingMap = new Map(bookings.map((booking) => [booking.uid, booking.attendees[0] || null]));
return csvData.map((bookingTimeStatus) => {
if (!bookingTimeStatus.uid) {
// should not be reached because we filtered above
return bookingTimeStatus;
}
const booker = bookingMap.get(bookingTimeStatus.uid);
if (!booker) {
return bookingTimeStatus;
}
return {
...bookingTimeStatus,
bookerEmail: booker.email,
bookerName: booker.name,
};
});
};
/*
@@ -288,8 +288,8 @@ export const insightsRouter = router({
const baseWhereCondition = {
...whereConditional,
createdAt: {
gte: new Date(startDate),
lte: new Date(endDate),
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
@@ -417,11 +417,11 @@ export const insightsRouter = router({
userId: selfUserId,
} = input;
const startDate = dayjs(startDateString);
const endDate = dayjs(endDateString);
const user = ctx.user;
// Convert to UTC without shifting the time zone
let startDate = dayjs.utc(startDateString).startOf("day");
let endDate = dayjs.utc(endDateString).endOf("day");
if (selfUserId && user?.id !== selfUserId) {
if (selfUserId && ctx.user?.id !== selfUserId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
@@ -431,12 +431,17 @@ export const insightsRouter = router({
let timeView = inputTimeView;
if (timeView === "week") {
// Difference between start and end date is less than 14 days use day view
if (endDate.diff(startDate, "day") < 14) {
timeView = "day";
}
// Adjust the timeView if the range is less than 14 days
if (timeView === "week" && endDate.diff(startDate, "day") < 14) {
timeView = "day";
}
// Align startDate to the Monday of the week if timeView is 'week'
if (timeView === "week") {
startDate = startDate.day(1); // Set startDate to Monday
endDate = endDate.day(1).add(6, "day").endOf("day"); // Set endDate to Sunday of the same week
}
const r = await buildBaseWhereCondition({
teamId,
eventTypeId,
@@ -450,53 +455,59 @@ export const insightsRouter = router({
},
});
let { whereCondition: whereConditional } = r;
const { whereCondition: whereConditional } = r;
const timeline = await EventsInsights.getTimeLine(timeView, startDate, endDate);
// Get timeline data
const timeline = await EventsInsights.getTimeLine(timeView, dayjs(startDate), dayjs(endDate));
// iterate timeline and fetch data
if (!timeline) {
return [];
}
const dateFormat: string = timeView === "year" ? "YYYY" : timeView === "month" ? "MMM YYYY" : "ll";
const result = [];
for (const date of timeline) {
// Align date ranges consistently with weeks starting on Monday
const dateRanges = timeline.map((date) => {
let startOfRange = dayjs.utc(date).startOf(timeView);
let endOfRange = dayjs.utc(date).endOf(timeView);
if (timeView === "week") {
startOfRange = dayjs.utc(date).day(1); // Start at Monday in UTC
endOfRange = startOfRange.add(6, "day").endOf("day"); // End at Sunday in UTC
}
return {
startDate: startOfRange.toISOString(),
endDate: endOfRange.toISOString(),
formattedDate: startOfRange.format(dateFormat), // Align formatted date for consistency
};
});
// Fetch counts grouped by status for the entire range
const countsByStatus = await EventsInsights.countGroupedByStatusForRanges(
whereConditional,
startDate,
endDate,
timeView
);
const result = dateRanges.map(({ formattedDate }) => {
const EventData = {
Month: dayjs(date).format(dateFormat),
Month: formattedDate,
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
"No-Show (Host)": 0,
};
const startOfEndOf = timeView;
let startDate = dayjs(date).startOf(startOfEndOf);
let endDate = dayjs(date).endOf(startOfEndOf);
if (timeView === "week") {
startDate = dayjs(date).startOf("day");
endDate = dayjs(date).add(6, "day").endOf("day");
const countsForDateRange = countsByStatus[formattedDate];
if (countsForDateRange) {
EventData["Created"] = countsForDateRange["_all"] || 0;
EventData["Completed"] = countsForDateRange["completed"] || 0;
EventData["Rescheduled"] = countsForDateRange["rescheduled"] || 0;
EventData["Cancelled"] = countsForDateRange["cancelled"] || 0;
EventData["No-Show (Host)"] = countsForDateRange["noShowHost"] || 0;
}
whereConditional = {
...whereConditional,
createdAt: {
gte: startDate.toISOString(),
lte: endDate.toISOString(),
},
};
const countsByStatus = await EventsInsights.countGroupedByStatus(whereConditional);
EventData["Created"] = countsByStatus["_all"];
EventData["Completed"] = countsByStatus["completed"];
EventData["Rescheduled"] = countsByStatus["rescheduled"];
EventData["Cancelled"] = countsByStatus["cancelled"];
EventData["No-Show (Host)"] = countsByStatus["noShowHost"];
result.push(EventData);
}
return EventData;
});
return result;
}),
@@ -672,8 +683,8 @@ export const insightsRouter = router({
return [];
}
const startDate = dayjs(startDateString);
const endDate = dayjs(endDateString);
const startDate = dayjs.utc(startDateString).startOf("day");
const endDate = dayjs.utc(endDateString).endOf("day");
const { whereCondition: whereConditional } = await buildBaseWhereCondition({
teamId,
@@ -695,43 +706,42 @@ export const insightsRouter = router({
return [];
}
const dateFormat = "ll";
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const result = [];
const allBookings = await ctx.insightsDb.bookingTimeStatus.findMany({
select: {
eventLength: true,
createdAt: true,
},
where: {
...whereConditional,
createdAt: {
gte: startDate.toDate(),
lte: endDate.toDate(),
},
},
});
const resultMap = new Map<string, { totalDuration: number; count: number }>();
for (const date of timeLine) {
const EventData = {
Date: dayjs(date).format(dateFormat),
Average: 0,
};
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const startDate = dayjs(date).startOf(startOfEndOf);
const endDate = dayjs(date).endOf(startOfEndOf);
const bookingsInTimeRange = await ctx.insightsDb.bookingTimeStatus.findMany({
select: {
eventLength: true,
},
where: {
...whereConditional,
createdAt: {
gte: startDate.toDate(),
lte: endDate.toDate(),
},
},
});
const avgDuration =
bookingsInTimeRange.reduce((acc, booking) => {
const duration = booking.eventLength || 0;
return acc + duration;
}, 0) / bookingsInTimeRange.length;
EventData["Average"] = Number(avgDuration) || 0;
result.push(EventData);
resultMap.set(dayjs(date).startOf(startOfEndOf).format("ll"), { totalDuration: 0, count: 0 });
}
for (const booking of allBookings) {
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
if (resultMap.has(periodStart)) {
const current = resultMap.get(periodStart)!;
current.totalDuration += booking.eventLength || 0;
current.count += 1;
}
}
const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
Date: date,
Average: count > 0 ? totalDuration / count : 0,
}));
return result;
}),
membersWithMostBookings: userBelongsToTeamProcedure
@@ -771,7 +781,6 @@ export const insightsRouter = router({
bookingWhere = {
...bookingWhere,
teamId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
@@ -863,8 +872,6 @@ export const insightsRouter = router({
bookingWhere = {
...bookingWhere,
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
@@ -48,40 +48,46 @@ export const useSchedule = ({
const searchParams = useSearchParams();
const routedTeamMemberIds = searchParams ? getRoutedTeamMemberIdsFromSearchParams(searchParams) : null;
const skipContactOwner = searchParams ? searchParams.get("cal.skipContactOwner") === "true" : false;
return trpc.viewer.public.slots.getSchedule.useQuery(
{
isTeamEvent,
usernameList: getUsernameList(username ?? ""),
// Prioritize slug over id, since slug is the first value we get available.
// If we have a slug, we don't need to fetch the id.
// TODO: are queries using eventTypeId faster? Even tho we lost time fetching the id with the slug.
...(eventSlug ? { eventTypeSlug: eventSlug } : { eventTypeId: eventId ?? 0 }),
// @TODO: Old code fetched 2 days ago if we were fetching the current month.
// Do we want / need to keep that behavior?
startTime,
// if `prefetchNextMonth` is true, two months are fetched at once.
endTime,
timeZone: timezone!,
duration: duration ? `${duration}` : undefined,
rescheduleUid,
orgSlug,
teamMemberEmail,
routedTeamMemberIds,
skipContactOwner,
},
{
trpc: {
context: {
skipBatch: true,
},
const input = {
isTeamEvent,
usernameList: getUsernameList(username ?? ""),
// Prioritize slug over id, since slug is the first value we get available.
// If we have a slug, we don't need to fetch the id.
// TODO: are queries using eventTypeId faster? Even tho we lost time fetching the id with the slug.
...(eventSlug ? { eventTypeSlug: eventSlug } : { eventTypeId: eventId ?? 0 }),
// @TODO: Old code fetched 2 days ago if we were fetching the current month.
// Do we want / need to keep that behavior?
startTime,
// if `prefetchNextMonth` is true, two months are fetched at once.
endTime,
timeZone: timezone!,
duration: duration ? `${duration}` : undefined,
rescheduleUid,
orgSlug,
teamMemberEmail,
routedTeamMemberIds,
skipContactOwner,
};
const options = {
trpc: {
context: {
skipBatch: true,
},
refetchOnWindowFocus: false,
enabled:
Boolean(username) &&
Boolean(month) &&
Boolean(timezone) &&
// Should only wait for one or the other, not both.
(Boolean(eventSlug) || Boolean(eventId) || eventId === 0),
}
);
},
refetchOnWindowFocus: false,
enabled:
Boolean(username) &&
Boolean(month) &&
Boolean(timezone) &&
// Should only wait for one or the other, not both.
(Boolean(eventSlug) || Boolean(eventId) || eventId === 0),
};
if (isTeamEvent) {
return trpc.viewer.highPerf.getTeamSchedule.useQuery(input, options);
}
return trpc.viewer.public.slots.getSchedule.useQuery(input, options);
};
@@ -15,15 +15,20 @@ export class InternalTasker implements Tasker {
};
async processQueue(): Promise<void> {
const tasks = await Task.getNextBatch();
console.info(`Processing ${tasks.length} tasks`, tasks);
const tasksPromises = tasks.map(async (task) => {
console.info(`Processing task ${task.id}`, task);
const taskHandlerGetter = tasksMap[task.type as keyof typeof tasksMap];
if (!taskHandlerGetter) throw new Error(`Task handler not found for type ${task.type}`);
const taskHandler = await taskHandlerGetter();
return taskHandler(task.payload)
.then(async () => {
await Task.succeed(task.id);
})
.catch(async (error) => {
console.info(`Error processing task ${task.id}: ${error}`);
await Task.retry(task.id, error instanceof Error ? error.message : "Unknown error");
});
});
+14 -11
View File
@@ -23,7 +23,7 @@ const whereUpcomingTasks: Prisma.TaskWhereInput = {
succeededAt: null,
// Get only tasks that are scheduled to run now or in the past
scheduledAt: {
lt: new Date(),
lt: new Date().toUTCString(),
},
// Get only tasks where maxAttemps has not been reached
attempts: {
@@ -42,6 +42,7 @@ export class Task {
options: { scheduledAt?: Date; maxAttempts?: number } = {}
) {
const { scheduledAt, maxAttempts } = options;
console.info("Creating task", { type, payload, scheduledAt, maxAttempts });
const newTask = await db.task.create({
data: {
payload,
@@ -54,6 +55,7 @@ export class Task {
}
static async getNextBatch() {
console.info("Getting next batch of tasks", whereUpcomingTasks);
return db.task.findMany({
where: whereUpcomingTasks,
orderBy: {
@@ -134,15 +136,16 @@ export class Task {
}
static async cleanup() {
return db.task.deleteMany({
where: {
OR: [
// Get tasks that have succeeded
whereSucceeded,
// Get tasks where maxAttemps has been reached
whereMaxAttemptsReached,
],
},
});
// TODO: Uncomment this later
// return db.task.deleteMany({
// where: {
// OR: [
// // Get tasks that have succeeded
// whereSucceeded,
// // Get tasks where maxAttemps has been reached
// whereMaxAttemptsReached,
// ],
// },
// });
}
}
@@ -9,7 +9,7 @@ import {
} from "@calcom/platform-enums/monorepo";
import type {
InputBookingField_2024_06_14,
Location_2024_06_14,
InputLocation_2024_06_14,
BookingLimitsCount_2024_06_14,
BookingWindow_2024_06_14,
BookerLayouts_2024_06_14,
@@ -19,9 +19,9 @@ import type {
CreateEventTypeInput_2024_06_14,
SeatOptionsTransformedSchema,
SeatOptionsDisabledSchema,
AttendeeAddressLocation_2024_06_14,
AttendeePhoneLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14,
InputAttendeeAddressLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputAttendeeDefinedLocation_2024_06_14,
} from "@calcom/platform-types";
import {
@@ -45,7 +45,7 @@ import {
describe("transformLocationsApiToInternal", () => {
it("should transform address", () => {
const input: Location_2024_06_14[] = [
const input: InputLocation_2024_06_14[] = [
{
type: "address",
address: "London road 10-1",
@@ -61,7 +61,7 @@ describe("transformLocationsApiToInternal", () => {
});
it("should transform link", () => {
const input: Location_2024_06_14[] = [
const input: InputLocation_2024_06_14[] = [
{
type: "link",
link: "https://customvideo.com/join/123456",
@@ -79,7 +79,7 @@ describe("transformLocationsApiToInternal", () => {
});
it("should transform integration", () => {
const input: Location_2024_06_14[] = [
const input: InputLocation_2024_06_14[] = [
{
type: "integration",
integration: "cal-video",
@@ -94,7 +94,7 @@ describe("transformLocationsApiToInternal", () => {
});
it("should transform phone", () => {
const input: Location_2024_06_14[] = [
const input: InputLocation_2024_06_14[] = [
{
type: "phone",
phone: "+37120993151",
@@ -112,7 +112,7 @@ describe("transformLocationsApiToInternal", () => {
});
it("should transform attendee address", () => {
const input: AttendeeAddressLocation_2024_06_14[] = [
const input: InputAttendeeAddressLocation_2024_06_14[] = [
{
type: "attendeeAddress",
},
@@ -125,7 +125,7 @@ describe("transformLocationsApiToInternal", () => {
});
it("should transform attendee phone", () => {
const input: AttendeePhoneLocation_2024_06_14[] = [
const input: InputAttendeePhoneLocation_2024_06_14[] = [
{
type: "attendeePhone",
},
@@ -138,7 +138,7 @@ describe("transformLocationsApiToInternal", () => {
});
it("should transform attendee defined", () => {
const input: AttendeeDefinedLocation_2024_06_14[] = [
const input: InputAttendeeDefinedLocation_2024_06_14[] = [
{
type: "attendeeDefined",
},
@@ -1,14 +1,25 @@
import { z } from "zod";
import type { InputLocation_2024_06_14 } from "@calcom/platform-types";
import { type Integration_2024_06_14 } from "@calcom/platform-types";
import { type CreateEventTypeInput_2024_06_14, type Integration_2024_06_14 } from "@calcom/platform-types";
import type {
AttendeeAddressLocation,
AttendeeDefinedLocation,
AttendeePhoneLocation,
OrganizerAddressLocation,
OrganizerIntegrationLocation,
OrganizerLinkLocation,
OrganizerPhoneLocation,
} from "../internal/locations";
const integrationsMapping: Record<Integration_2024_06_14, "integrations:daily"> = {
const apiToInternalintegrationsMapping: Record<
Integration_2024_06_14,
"integrations:daily" | "integrations:google:meet"
> = {
"cal-video": "integrations:daily",
"google-meet": "integrations:google:meet",
};
export function transformLocationsApiToInternal(
inputLocations: CreateEventTypeInput_2024_06_14["locations"]
) {
export function transformLocationsApiToInternal(inputLocations: InputLocation_2024_06_14[] | undefined) {
if (!inputLocations) {
return [];
}
@@ -31,7 +42,7 @@ export function transformLocationsApiToInternal(
displayLocationPublicly: location.public,
} satisfies OrganizerLinkLocation;
case "integration":
const integrationLabel = integrationsMapping[location.integration];
const integrationLabel = apiToInternalintegrationsMapping[location.integration];
return { type: integrationLabel } satisfies OrganizerIntegrationLocation;
case "phone":
return {
@@ -44,69 +55,7 @@ export function transformLocationsApiToInternal(
case "attendeeDefined":
return { type: "somewhereElse" } satisfies AttendeeDefinedLocation;
default:
throw new Error(`Unsupported location type '${type}'`);
throw new Error(`Unsupported input location type '${type}'`);
}
});
}
const integrationsMappingSchema = {
"cal-video": z.literal("integrations:daily"),
};
const OrganizerAddressSchema = z.object({
type: z.literal("inPerson"),
address: z.string(),
displayLocationPublicly: z.boolean().default(false),
});
const OrganizerLinkSchema = z.object({
type: z.literal("link"),
link: z.string().url(),
displayLocationPublicly: z.boolean().default(false),
});
const OrganizerIntegrationSchema = z.object({
type: z.union([integrationsMappingSchema["cal-video"], integrationsMappingSchema["cal-video"]]),
});
const OrganizerPhoneSchema = z.object({
type: z.literal("userPhone"),
hostPhoneNumber: z.string(),
displayLocationPublicly: z.boolean().default(false),
});
const OrganizerConferencingSchema = z.object({
type: z.literal("conferencing"),
});
const AttendeeAddressSchema = z.object({
type: z.literal("attendeeInPerson"),
});
const AttendeePhoneSchema = z.object({
type: z.literal("phone"),
});
const AttendeeDefinedSchema = z.object({
type: z.literal("somewhereElse"),
});
type OrganizerAddressLocation = z.infer<typeof OrganizerAddressSchema>;
type OrganizerLinkLocation = z.infer<typeof OrganizerLinkSchema>;
export type OrganizerIntegrationLocation = z.infer<typeof OrganizerIntegrationSchema>;
type OrganizerPhoneLocation = z.infer<typeof OrganizerPhoneSchema>;
type AttendeeAddressLocation = z.infer<typeof AttendeeAddressSchema>;
type AttendeePhoneLocation = z.infer<typeof AttendeePhoneSchema>;
type AttendeeDefinedLocation = z.infer<typeof AttendeeDefinedSchema>;
const TransformedLocationSchema = z.union([
OrganizerAddressSchema,
OrganizerLinkSchema,
OrganizerIntegrationSchema,
OrganizerPhoneSchema,
OrganizerConferencingSchema,
AttendeeAddressSchema,
AttendeePhoneSchema,
AttendeeDefinedSchema,
]);
export const TransformedLocationsSchema = z.array(TransformedLocationSchema);
@@ -1,2 +1,3 @@
export * from "./api-to-internal";
export * from "./internal-to-api";
export * from "./internal/locations";
@@ -5,6 +5,7 @@ import type {
DefaultFieldOutput_2024_06_14,
CustomFieldOutput_2024_06_14,
} from "@calcom/platform-types";
import type { OutputUnknownBookingField_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/outputs/booking-fields.output";
export function transformBookingFieldsInternalToApi(
databaseBookingFields: (SystemField | CustomField)[]
@@ -17,186 +18,201 @@ export function transformBookingFieldsInternalToApi(
(field): field is CustomField => field.editable === "user"
);
const responseDefaultFields: DefaultFieldOutput_2024_06_14[] = defaultFields.map((field) => {
switch (field.name) {
case "name":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
label: field.label,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "email":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
label: field.label,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "location":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "rescheduleReason":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "title":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "notes":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "guests":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
default:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
throw new Error(`Unsupported default booking field '${field.name}'.`);
}
});
const responseDefaultFields: (DefaultFieldOutput_2024_06_14 | OutputUnknownBookingField_2024_06_14)[] =
defaultFields.map((field) => {
switch (field.name) {
case "name":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
label: field.label,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "email":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
label: field.label,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "location":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "rescheduleReason":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "title":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "notes":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "guests":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
case "attendeePhoneNumber":
return {
isDefault: true,
type: field.type,
slug: field.name,
required: field.required,
};
default:
return {
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(field),
};
}
});
const responseCustomFields: CustomFieldOutput_2024_06_14[] = customFields.map((field) => {
switch (field.type) {
case "phone":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "address":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "text":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "number":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "textarea":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "multiemail":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "boolean":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
disableOnPrefill: field.disableOnPrefill,
};
case "select":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
options: field.options ? field.options.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
case "multiselect":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
options: field.options ? field.options?.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
case "checkbox":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
options: field.options ? field.options?.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
case "radio":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
options: field.options ? field.options?.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
default:
throw new Error(`Unsupported booking field type '${field.type}'.`);
}
});
const responseCustomFields: (CustomFieldOutput_2024_06_14 | OutputUnknownBookingField_2024_06_14)[] =
customFields.map((field) => {
switch (field.type) {
case "phone":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "address":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "text":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "number":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "textarea":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "multiemail":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
disableOnPrefill: field.disableOnPrefill,
};
case "boolean":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
disableOnPrefill: field.disableOnPrefill,
};
case "select":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
placeholder: field.placeholder,
options: field.options ? field.options.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
case "multiselect":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
options: field.options ? field.options?.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
case "checkbox":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
options: field.options ? field.options?.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
case "radio":
return {
isDefault: false,
type: field.type,
slug: field.name,
label: field.label,
required: field.required,
options: field.options ? field.options?.map((option) => option.value) : [],
disableOnPrefill: field.disableOnPrefill,
};
default:
return {
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(field),
};
}
});
return [...responseDefaultFields, ...responseCustomFields];
}
@@ -316,6 +332,12 @@ const NameSystemFieldSchema = SystemFieldSchema.extend({
.optional(),
});
const PhoneSystemFieldSchema = SystemFieldSchema.extend({
name: z.literal("attendeePhoneNumber"),
type: z.literal("phone"),
required: z.boolean(),
});
const EmailSystemFieldSchema = SystemFieldSchema.extend({
name: z.literal("email"),
type: z.literal("email"),
@@ -359,10 +381,12 @@ type LocationReasonSystemField = z.infer<typeof LocationReasonSystemFieldSchema>
type TitleSystemField = z.infer<typeof TitleSystemFieldSchema>;
type NotesSystemField = z.infer<typeof NotesSystemFieldSchema>;
type GuestsSystemField = z.infer<typeof GuestsSystemFieldSchema>;
type PhoneSystemField = z.infer<typeof PhoneSystemFieldSchema>;
export type SystemField =
| NameSystemField
| EmailSystemField
| PhoneSystemField
| RescheduleReasonSystemField
| LocationReasonSystemField
| TitleSystemField
@@ -374,6 +398,7 @@ export type CustomField = z.infer<typeof CustomFieldsSchema>;
const SystemFieldsSchema = z.union([
NameSystemFieldSchema,
EmailSystemFieldSchema,
PhoneSystemFieldSchema,
LocationReasonSystemFieldSchema,
RescheduleReasonSystemFieldSchema,
TitleSystemFieldSchema,
@@ -381,7 +406,8 @@ const SystemFieldsSchema = z.union([
GuestsSystemFieldSchema,
]);
export const BookingFieldsSchema = z.array(z.union([CustomFieldsSchema, SystemFieldsSchema]));
export const BookingFieldSchema = z.union([CustomFieldsSchema, SystemFieldsSchema]);
export const BookingFieldsSchema = z.array(BookingFieldSchema);
export const systemBeforeFieldName: NameSystemField = {
type: "name",
@@ -7,10 +7,6 @@ import {
NoticeThresholdUnitEnum,
} from "@calcom/platform-enums/monorepo";
import type {
AddressLocation_2024_06_14,
LinkLocation_2024_06_14,
PhoneLocation_2024_06_14,
IntegrationLocation_2024_06_14,
TransformBookingLimitsSchema_2024_06_14,
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
@@ -18,9 +14,14 @@ import type {
TransformRecurringEventSchema_2024_06_14,
SeatOptionsTransformedSchema,
SeatOptionsDisabledSchema,
AttendeeAddressLocation_2024_06_14,
AttendeePhoneLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14,
OutputAddressLocation_2024_06_14,
OutputAttendeeAddressLocation_2024_06_14,
OutputAttendeeDefinedLocation_2024_06_14,
OutputAttendeePhoneLocation_2024_06_14,
OutputIntegrationLocation_2024_06_14,
OutputLinkLocation_2024_06_14,
OutputPhoneLocation_2024_06_14,
OutputUnknownLocation_2024_06_14,
} from "@calcom/platform-types";
import {
@@ -51,7 +52,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: AddressLocation_2024_06_14[] = [
const expectedOutput: OutputAddressLocation_2024_06_14[] = [
{
type: "address",
address: "1234 Main St",
@@ -73,7 +74,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: LinkLocation_2024_06_14[] = [
const expectedOutput: OutputLinkLocation_2024_06_14[] = [
{
type: "link",
link: "https://example.com",
@@ -95,7 +96,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: PhoneLocation_2024_06_14[] = [
const expectedOutput: OutputPhoneLocation_2024_06_14[] = [
{
type: "phone",
phone: "123456789",
@@ -115,7 +116,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: IntegrationLocation_2024_06_14[] = [
const expectedOutput: OutputIntegrationLocation_2024_06_14[] = [
{
type: "integration",
integration: "cal-video",
@@ -127,6 +128,91 @@ describe("transformLocationsInternalToApi", () => {
expect(result).toEqual(expectedOutput);
});
it("should transform integration location", () => {
const transformedLocation = [
{
type: "integrations:discord_video" as const,
},
];
const expectedOutput: OutputIntegrationLocation_2024_06_14[] = [
{
type: "integration",
integration: "discord-video",
},
];
const result = transformLocationsInternalToApi(transformedLocation);
expect(result).toEqual(expectedOutput);
});
it("should transform integration location with link and credentialId", () => {
const transformedLocation = [
{
type: "integrations:discord_video" as const,
link: "https://discord.com/users/100",
credentialId: 100,
},
];
const expectedOutput: OutputIntegrationLocation_2024_06_14[] = [
{
type: "integration",
integration: "discord-video",
link: "https://discord.com/users/100",
credentialId: 100,
},
];
const result = transformLocationsInternalToApi(transformedLocation);
expect(result).toEqual(expectedOutput);
});
it("should transform unknown location", () => {
const transformedLocation = [
{
type: "unknown" as const,
location: "unknown location",
},
];
const expectedOutput: OutputUnknownLocation_2024_06_14[] = [
{
type: "unknown",
location: JSON.stringify(transformedLocation[0]),
},
];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = transformLocationsInternalToApi(transformedLocation);
expect(result).toEqual(expectedOutput);
});
it("should transform unknown integration location", () => {
const transformedLocation = [
{
type: "integrations:unknown_video" as const,
},
];
const expectedOutput: OutputUnknownLocation_2024_06_14[] = [
{
type: "unknown",
location: JSON.stringify(transformedLocation[0]),
},
];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = transformLocationsInternalToApi(transformedLocation);
expect(result).toEqual(expectedOutput);
});
it("should reverse transform attendee address location", () => {
const transformedLocation = [
{
@@ -134,7 +220,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: AttendeeAddressLocation_2024_06_14[] = [
const expectedOutput: OutputAttendeeAddressLocation_2024_06_14[] = [
{
type: "attendeeAddress",
},
@@ -151,7 +237,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: AttendeePhoneLocation_2024_06_14[] = [
const expectedOutput: OutputAttendeePhoneLocation_2024_06_14[] = [
{
type: "attendeePhone",
},
@@ -168,7 +254,7 @@ describe("transformLocationsInternalToApi", () => {
},
];
const expectedOutput: AttendeeDefinedLocation_2024_06_14[] = [
const expectedOutput: OutputAttendeeDefinedLocation_2024_06_14[] = [
{
type: "attendeeDefined",
},
@@ -208,11 +294,11 @@ describe("transformBookingFieldsInternalToApi", () => {
fullName: {
fields: [
{
name: "fullName",
name: "fullName" as const,
label: "custom label",
placeholder: "custom placeholder",
type: "text",
required: true,
type: "text" as const,
required: true as const,
},
],
},
@@ -690,6 +776,73 @@ describe("transformBookingFieldsInternalToApi", () => {
expect(result).toEqual(expectedOutput);
});
it("should transform system phone field", () => {
const transformedField: SystemField[] = [
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
];
const expectedOutput = [
{
isDefault: true,
type: "phone",
slug: "attendeePhoneNumber",
required: false,
},
];
const result = transformBookingFieldsInternalToApi(transformedField);
expect(result).toEqual(expectedOutput);
});
it("should transform unknown field", () => {
const transformedField = [
{
name: "blabla",
type: "blabla",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
];
const expectedOutput = [
{
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(transformedField[0]),
},
];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = transformBookingFieldsInternalToApi(transformedField);
expect(result).toEqual(expectedOutput);
});
});
describe("transformIntervalLimitsInternalToApi", () => {
@@ -1,34 +1,64 @@
import type {
AddressLocation_2024_06_14,
IntegrationLocation_2024_06_14,
LinkLocation_2024_06_14,
PhoneLocation_2024_06_14,
Integration_2024_06_14,
AttendeePhoneLocation_2024_06_14,
AttendeeAddressLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14,
OutputConferencingLocation_2024_06_14,
OutputIntegration_2024_06_14,
OutputIntegrationLocation_2024_06_14,
OutputAddressLocation_2024_06_14,
OutputAttendeeAddressLocation_2024_06_14,
OutputAttendeeDefinedLocation_2024_06_14,
OutputAttendeePhoneLocation_2024_06_14,
OutputLinkLocation_2024_06_14,
OutputPhoneLocation_2024_06_14,
OutputUnknownLocation_2024_06_14,
} from "@calcom/platform-types";
import type { transformLocationsApiToInternal } from "../api-to-internal";
import type { InternalLocation } from "../internal/locations";
const reverseIntegrationsMapping: Record<string, Integration_2024_06_14> = {
const internalToApiIntegrationsMapping: Record<string, OutputIntegration_2024_06_14> = {
"integrations:daily": "cal-video",
"integrations:google:meet": "google-meet",
"integrations:zoom": "zoom",
"integrations:whereby_video": "whereby-video",
"integrations:whatsapp_video": "whatsapp-video",
"integrations:webex_video": "webex-video",
"integrations:telegram_video": "telegram-video",
"integrations:tandem": "tandem",
"integrations:sylaps_video": "sylaps-video",
"integrations:skype_video": "skype-video",
"integrations:sirius_video_video": "sirius-video",
"integrations:signal_video": "signal-video",
"integrations:shimmer_video": "shimmer-video",
"integrations:salesroom_video": "salesroom-video",
"integrations:roam_video": "roam-video",
"integrations:riverside_video": "riverside-video",
"integrations:ping_video": "ping-video",
"integrations:office365_video": "office365-video",
"integrations:mirotalk_video": "mirotalk-video",
"integrations:jitsi": "jitsi",
"integrations:jelly_video": "jelly-video",
"integrations:jelly_conferencing": "jelly-conferencing",
"integrations:huddle01": "huddle",
"integrations:facetime_video": "facetime-video",
"integrations:element-call_video": "element-call-video",
"integrations:eightxeight_video": "eightxeight-video",
"integrations:discord_video": "discord-video",
"integrations:demodesk_video": "demodesk-video",
"integrations:campsite_conferencing": "campsite-conferencing",
"integrations:campfire_video": "campfire-video",
"integrations:around_video": "around-video",
};
export function transformLocationsInternalToApi(
transformedLocations: ReturnType<typeof transformLocationsApiToInternal>
) {
if (!transformedLocations) {
export function transformLocationsInternalToApi(internalLocations: InternalLocation[] | undefined) {
if (!internalLocations) {
return [];
}
return transformedLocations.map((location) => {
return internalLocations.map((location) => {
switch (location.type) {
case "inPerson": {
if (!location.address) {
throw new Error("Address location must have an address");
}
const addressLocation: AddressLocation_2024_06_14 = {
const addressLocation: OutputAddressLocation_2024_06_14 = {
type: "address",
address: location.address,
public: location.displayLocationPublicly,
@@ -36,7 +66,7 @@ export function transformLocationsInternalToApi(
return addressLocation;
}
case "attendeeInPerson": {
const attendeeAddressLocation: AttendeeAddressLocation_2024_06_14 = {
const attendeeAddressLocation: OutputAttendeeAddressLocation_2024_06_14 = {
type: "attendeeAddress",
};
return attendeeAddressLocation;
@@ -45,7 +75,7 @@ export function transformLocationsInternalToApi(
if (!location.link) {
throw new Error("Link location must have a link");
}
const linkLocation: LinkLocation_2024_06_14 = {
const linkLocation: OutputLinkLocation_2024_06_14 = {
type: "link",
link: location.link,
public: location.displayLocationPublicly,
@@ -56,7 +86,7 @@ export function transformLocationsInternalToApi(
if (!location.hostPhoneNumber) {
throw new Error("Phone location must have a phone number");
}
const phoneLocation: PhoneLocation_2024_06_14 = {
const phoneLocation: OutputPhoneLocation_2024_06_14 = {
type: "phone",
phone: location.hostPhoneNumber,
public: location.displayLocationPublicly,
@@ -64,25 +94,37 @@ export function transformLocationsInternalToApi(
return phoneLocation;
}
case "phone": {
const attendeePhoneLocation: AttendeePhoneLocation_2024_06_14 = {
const attendeePhoneLocation: OutputAttendeePhoneLocation_2024_06_14 = {
type: "attendeePhone",
};
return attendeePhoneLocation;
}
case "somewhereElse": {
const attendeeDefinedLocation: AttendeeDefinedLocation_2024_06_14 = {
const attendeeDefinedLocation: OutputAttendeeDefinedLocation_2024_06_14 = {
type: "attendeeDefined",
};
return attendeeDefinedLocation;
}
case "conferencing": {
const conferencingLocation: OutputConferencingLocation_2024_06_14 = {
type: "conferencing",
};
return conferencingLocation;
}
default: {
const integrationType = reverseIntegrationsMapping[location.type];
const integrationType = internalToApiIntegrationsMapping[location.type];
if (!integrationType) {
throw new Error(`Unsupported integration type '${location.type}'.`);
const unknown: OutputUnknownLocation_2024_06_14 = {
type: "unknown",
location: JSON.stringify(location),
};
return unknown;
}
const integration: IntegrationLocation_2024_06_14 = {
const integration: OutputIntegrationLocation_2024_06_14 = {
type: "integration",
integration: integrationType,
link: location.link,
credentialId: location.credentialId,
};
return integration;
}
@@ -0,0 +1,139 @@
import { z } from "zod";
const integrationsApiAvailable = {
"cal-video": z.literal("integrations:daily"),
"google-meet": z.literal("integrations:google:meet"),
};
// note(Lauris): these are read only aka they exist in database and are to be returned by a READ operation
const integrationsApiUnavailable = {
zoom: z.literal("integrations:zoom"),
"whereby-video": z.literal("integrations:whereby_video"),
"whatsapp-video": z.literal("integrations:whatsapp_video"),
"webex-video": z.literal("integrations:webex_video"),
"telegram-video": z.literal("integrations:telegram_video"),
tandem: z.literal("integrations:tandem"),
"sylaps-video": z.literal("integrations:sylaps_video"),
"skype-video": z.literal("integrations:skype_video"),
"sirius-video": z.literal("integrations:sirius_video_video"),
"signal-video": z.literal("integrations:signal_video"),
"shimmer-video": z.literal("integrations:shimmer_video"),
"salesroom-video": z.literal("integrations:salesroom_video"),
"roam-video": z.literal("integrations:roam_video"),
"riverside-video": z.literal("integrations:riverside_video"),
"ping-video": z.literal("integrations:ping_video"),
"office365-video": z.literal("integrations:office365_video"),
"mirotalk-video": z.literal("integrations:mirotalk_video"),
jitsi: z.literal("integrations:jitsi"),
"jelly-video": z.literal("integrations:jelly_video"),
"jelly-conferencing": z.literal("integrations:jelly_conferencing"),
huddle: z.literal("integrations:huddle01"),
"facetime-video": z.literal("integrations:facetime_video"),
"element-call-video": z.literal("integrations:element-call_video"),
"eightxeight-video": z.literal("integrations:eightxeight_video"),
"discord-video": z.literal("integrations:discord_video"),
"demodesk-video": z.literal("integrations:demodesk_video"),
"campsite-conferencing": z.literal("integrations:campsite_conferencing"),
"campfire-video": z.literal("integrations:campfire_video"),
"around-video": z.literal("integrations:around_video"),
};
export const integrationsApiToInternalMappingSchema = {
...integrationsApiAvailable,
...integrationsApiUnavailable,
};
const OrganizerIntegrationSchema = z.object({
type: z.union([
integrationsApiToInternalMappingSchema["cal-video"],
integrationsApiToInternalMappingSchema["google-meet"],
integrationsApiToInternalMappingSchema["zoom"],
integrationsApiToInternalMappingSchema["whereby-video"],
integrationsApiToInternalMappingSchema["whatsapp-video"],
integrationsApiToInternalMappingSchema["webex-video"],
integrationsApiToInternalMappingSchema["telegram-video"],
integrationsApiToInternalMappingSchema["tandem"],
integrationsApiToInternalMappingSchema["sylaps-video"],
integrationsApiToInternalMappingSchema["skype-video"],
integrationsApiToInternalMappingSchema["sirius-video"],
integrationsApiToInternalMappingSchema["signal-video"],
integrationsApiToInternalMappingSchema["shimmer-video"],
integrationsApiToInternalMappingSchema["salesroom-video"],
integrationsApiToInternalMappingSchema["roam-video"],
integrationsApiToInternalMappingSchema["riverside-video"],
integrationsApiToInternalMappingSchema["ping-video"],
integrationsApiToInternalMappingSchema["office365-video"],
integrationsApiToInternalMappingSchema["mirotalk-video"],
integrationsApiToInternalMappingSchema["jitsi"],
integrationsApiToInternalMappingSchema["jelly-video"],
integrationsApiToInternalMappingSchema["jelly-conferencing"],
integrationsApiToInternalMappingSchema["huddle"],
integrationsApiToInternalMappingSchema["facetime-video"],
integrationsApiToInternalMappingSchema["element-call-video"],
integrationsApiToInternalMappingSchema["eightxeight-video"],
integrationsApiToInternalMappingSchema["discord-video"],
integrationsApiToInternalMappingSchema["demodesk-video"],
integrationsApiToInternalMappingSchema["campsite-conferencing"],
integrationsApiToInternalMappingSchema["campfire-video"],
integrationsApiToInternalMappingSchema["around-video"],
]),
link: z.string().url().optional(),
credentialId: z.number().optional(),
});
const OrganizerAddressSchema = z.object({
type: z.literal("inPerson"),
address: z.string(),
displayLocationPublicly: z.boolean().default(false),
});
const OrganizerLinkSchema = z.object({
type: z.literal("link"),
link: z.string().url(),
displayLocationPublicly: z.boolean().default(false),
});
const OrganizerPhoneSchema = z.object({
type: z.literal("userPhone"),
hostPhoneNumber: z.string(),
displayLocationPublicly: z.boolean().default(false),
});
const OrganizerConferencingSchema = z.object({
type: z.literal("conferencing"),
});
const AttendeeAddressSchema = z.object({
type: z.literal("attendeeInPerson"),
});
const AttendeePhoneSchema = z.object({
type: z.literal("phone"),
});
const AttendeeDefinedSchema = z.object({
type: z.literal("somewhereElse"),
});
export type OrganizerAddressLocation = z.infer<typeof OrganizerAddressSchema>;
export type OrganizerLinkLocation = z.infer<typeof OrganizerLinkSchema>;
export type OrganizerIntegrationLocation = z.infer<typeof OrganizerIntegrationSchema>;
export type OrganizerPhoneLocation = z.infer<typeof OrganizerPhoneSchema>;
export type OrganizerConferencingSchema = z.infer<typeof OrganizerConferencingSchema>;
export type AttendeeAddressLocation = z.infer<typeof AttendeeAddressSchema>;
export type AttendeePhoneLocation = z.infer<typeof AttendeePhoneSchema>;
export type AttendeeDefinedLocation = z.infer<typeof AttendeeDefinedSchema>;
export const InternalLocationSchema = z.union([
OrganizerAddressSchema,
OrganizerLinkSchema,
OrganizerIntegrationSchema,
OrganizerPhoneSchema,
OrganizerConferencingSchema,
AttendeeAddressSchema,
AttendeePhoneSchema,
AttendeeDefinedSchema,
]);
export type InternalLocation = z.infer<typeof InternalLocationSchema>;
export const InternalLocationsSchema = z.array(InternalLocationSchema);
+16 -31
View File
@@ -1,53 +1,38 @@
import type { ITimezoneOption } from "react-timezone-select";
import dayjs from "@calcom/dayjs";
import type { ICity } from "@calcom/ui/components/form/timezone-select";
import { timezones as additionalTimezones } from "../features/additionalTimezones/additionalTimezones";
import isProblematicTimezone from "./isProblematicTimezone";
function findPartialMatch(itemsToSearch: string, searchString: string) {
const searchItems = searchString.split(" ");
return searchItems.every((i) => itemsToSearch.toLowerCase().indexOf(i.toLowerCase()) >= 0);
}
export type Timezones = { label: string; timezone: string }[];
function findFromCity(searchString: string, data: ICity[]): ICity[] {
if (searchString) {
const cityLookup = data.filter((o) => findPartialMatch(o.city, searchString));
return cityLookup?.length ? cityLookup : [];
}
return [];
}
export const filterByCities = (tz: string, data: ICity[]): ICity[] => {
const cityLookup = findFromCity(tz, data);
return cityLookup.map(({ city, timezone }) => ({ city, timezone }));
const searchTextFilter = (tzOption: Timezones[number], searchText: string) => {
return searchText && tzOption.label.toLowerCase().includes(searchText.toLowerCase());
};
export const addCitiesToDropdown = (cities: ICity[]) => {
const cityTimezones = cities?.reduce((acc: { [key: string]: string }, city: ICity) => {
if (city.timezone !== null && !isProblematicTimezone(city.timezone)) {
acc[city.timezone] = city.city;
}
return acc;
}, {});
export const filterBySearchText = (searchText: string, timezones: Timezones) => {
return timezones.filter((tzOption) => searchTextFilter(tzOption, searchText));
};
additionalTimezones.forEach((tz) => {
cityTimezones[tz.identifier] = tz.name;
});
return cityTimezones || {};
export const addTimezonesToDropdown = (timezones: Timezones) => {
return Object.fromEntries(
timezones
.filter(({ timezone }) => {
return timezone !== null && !isProblematicTimezone(timezone);
})
.map(({ label, timezone }) => [timezone, label])
);
};
const formatOffset = (offset: string) =>
offset.replace(/^([-+])(0)(\d):00$/, (_, sign, _zero, hour) => `${sign}${hour}:00`);
export const handleOptionLabel = (option: ITimezoneOption, cities: ICity[]) => {
export const handleOptionLabel = (option: ITimezoneOption, timezones: Timezones) => {
const offsetUnit = option.label.split("-")[0].substring(1);
const cityName = option.label.split(") ")[1];
const timezoneValue = ` ${offsetUnit} ${formatOffset(dayjs.tz(undefined, option.value).format("Z"))}`;
return cities.length > 0
return timezones.length > 0
? `${cityName}${timezoneValue}`
: `${option.value.replace(/_/g, " ")}${timezoneValue}`;
};
@@ -14,6 +14,7 @@ import type {
CustomFieldOutput_2024_06_14,
EmailDefaultFieldOutput_2024_06_14,
EventTypeOutput_2024_06_14,
InputLocation_2024_06_14,
NameDefaultFieldOutput_2024_06_14,
TeamEventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@@ -199,7 +200,9 @@ function isDefaultEvent(eventSlug: string) {
}
function getLocations(locations: EventTypeOutput_2024_06_14["locations"]) {
const transformed = transformLocationsApiToInternal(locations);
const transformed = transformLocationsApiToInternal(
locations.filter((location) => isAtomSupportedLocation(location))
);
const withPrivateHidden = transformed.map((location) => {
const { displayLocationPublicly, type } = location;
@@ -224,6 +227,22 @@ function getLocations(locations: EventTypeOutput_2024_06_14["locations"]) {
return withPrivateHidden;
}
function isAtomSupportedLocation(
location: EventTypeOutput_2024_06_14["locations"][number]
): location is InputLocation_2024_06_14 {
const supportedIntegrations = ["cal-video", "google-meet"];
return (
location.type === "address" ||
location.type === "attendeeAddress" ||
location.type === "link" ||
location.type === "phone" ||
location.type === "attendeePhone" ||
location.type === "attendeeDefined" ||
(location.type === "integration" && supportedIntegrations.includes(location.integration))
);
}
function getBookingFields(
bookingFields: EventTypeOutput_2024_06_14["bookingFields"],
defaultFormValues: BookerPlatformWrapperAtomProps["defaultFormValues"] | undefined
@@ -274,7 +293,7 @@ function getBookingFields(
function isCustomField(
field: EventTypeOutput_2024_06_14["bookingFields"][number]
): field is CustomFieldOutput_2024_06_14 {
return !field.isDefault;
return field.type !== "unknown" && !field.isDefault;
}
function isDefaultEditableField(
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -88,8 +88,8 @@ export type { AppsStatus } from "@calcom/types/Calendar";
export { MINUTES_TO_BOOK } from "@calcom/lib/constants";
export { cityTimezonesHandler } from "@calcom/lib/cityTimezonesHandler";
export type { CityTimezones } from "@calcom/lib/cityTimezonesHandler";
export { cityTimezonesHandler } from "@calcom/features/cityTimezones/cityTimezonesHandler";
export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler";
export { TRPCError } from "@trpc/server";
export type { TUpdateInputSchema } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.schema";
@@ -126,8 +126,10 @@ export {
transformEventTypeColorsInternalToApi,
transformSeatsInternalToApi,
// note(Lauris): schemas
TransformedLocationsSchema,
InternalLocationsSchema,
InternalLocationSchema,
BookingFieldsSchema,
BookingFieldSchema,
// note(Lauris): constants
systemBeforeFieldName,
systemBeforeFieldEmail,
@@ -140,6 +142,7 @@ export type {
CustomField,
NameSystemField,
EmailSystemField,
InternalLocation,
} from "@calcom/lib/event-types/transformers";
export { parseBookingLimit, parseEventTypeColor } from "@calcom/lib";
@@ -102,7 +102,7 @@ export class CreateBookingInputPipe implements PipeTransform {
private isRecurringBookingInput(
value: CreateBookingInput
): value is CreateRecurringBookingInput_2024_08_13 {
return value.hasOwnProperty("eventTypeId");
return value.hasOwnProperty("recurrenceCount");
}
private isInstantBookingInput(value: CreateBookingInput): value is CreateInstantBookingInput_2024_08_13 {
@@ -145,85 +145,7 @@ export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_202
instant!: boolean;
}
export class CreateRecurringBookingInput_2024_08_13 {
@ApiProperty({
type: String,
description: "The start time of the booking in ISO 8601 format in UTC timezone.",
example: "2024-08-13T09:00:00Z",
})
@IsDateString()
start!: string;
@ApiProperty({
type: Number,
description: "The ID of the event type that is booked.",
example: 123,
})
@IsInt()
eventTypeId!: number;
@ApiProperty({
type: Attendee,
description: "The attendee's details.",
})
@ValidateNested()
@Type(() => Attendee)
attendee!: Attendee;
@ApiProperty({
type: [String],
description: "An optional list of guest emails attending the event.",
example: ["guest1@example.com", "guest2@example.com"],
required: false,
})
@IsArray()
@IsString({ each: true })
@IsOptional()
guests?: string[];
@ApiProperty({
type: String,
description:
"Deprecated - use 'location' instead. Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.",
example: "https://example.com/meeting",
required: false,
deprecated: true,
})
@IsUrl()
@IsOptional()
meetingUrl?: string;
@ApiProperty({
type: String,
description: "Location for this booking. Displayed in email and calendar event.",
example: "https://example.com/meeting",
required: false,
})
@IsUrl()
@IsOptional()
location?: string;
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// @ApiProperty({
// type: Object,
// description: "Optional metadata for the booking.",
// example: { key: "value" },
// required: false,
// })
// @IsObject()
// @IsOptional()
// metadata!: Record<string, unknown>;
@ApiProperty({
type: Object,
description: "Booking field responses.",
example: { customField: "customValue" },
required: false,
})
@IsObject()
@IsOptional()
bookingFieldsResponses?: Record<string, unknown>;
export class CreateRecurringBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 {
@ApiPropertyOptional({
type: Number,
description: `The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than
@@ -521,7 +521,7 @@ class InputBookingFieldValidator_2024_06_14 implements ValidatorConstraintInterf
boolean: BooleanFieldInput_2024_06_14,
};
private reservedSystemSlugs = ["location", "rescheduleReason"];
private reservedSystemSlugs = ["location", "rescheduleReason", "attendeePhoneNumber"];
async validate(bookingFields: { type: string; slug: string }[]) {
if (!Array.isArray(bookingFields)) {
@@ -54,16 +54,16 @@ import { DestinationCalendar_2024_06_14 } from "./destination-calendar.input";
import { Disabled_2024_06_14 } from "./disabled.input";
import { EventTypeColor_2024_06_14 } from "./event-type-color.input";
import {
AddressLocation_2024_06_14,
AttendeeAddressLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14,
AttendeePhoneLocation_2024_06_14,
IntegrationLocation_2024_06_14,
LinkLocation_2024_06_14,
PhoneLocation_2024_06_14,
InputAddressLocation_2024_06_14,
InputAttendeeAddressLocation_2024_06_14,
InputAttendeeDefinedLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputIntegrationLocation_2024_06_14,
InputLinkLocation_2024_06_14,
InputPhoneLocation_2024_06_14,
ValidateLocations_2024_06_14,
} from "./locations.input";
import type { Location_2024_06_14 } from "./locations.input";
import type { InputLocation_2024_06_14 } from "./locations.input";
import { Recurrence_2024_06_14 } from "./recurrence.input";
import { Seats_2024_06_14 } from "./seats.input";
@@ -74,10 +74,10 @@ export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
export const CREATE_EVENT_SLUG_EXAMPLE = "learn-the-secrets-of-masterchief";
@ApiExtraModels(
AddressLocation_2024_06_14,
LinkLocation_2024_06_14,
IntegrationLocation_2024_06_14,
PhoneLocation_2024_06_14,
InputAddressLocation_2024_06_14,
InputLinkLocation_2024_06_14,
InputIntegrationLocation_2024_06_14,
InputPhoneLocation_2024_06_14,
PhoneFieldInput_2024_06_14,
AddressFieldInput_2024_06_14,
TextFieldInput_2024_06_14,
@@ -98,9 +98,9 @@ export const CREATE_EVENT_SLUG_EXAMPLE = "learn-the-secrets-of-masterchief";
Recurrence_2024_06_14,
BaseConfirmationPolicy_2024_06_14,
Seats_2024_06_14,
AttendeeAddressLocation_2024_06_14,
AttendeePhoneLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14
InputAttendeeAddressLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputAttendeeDefinedLocation_2024_06_14
)
export class CreateEventTypeInput_2024_06_14 {
@IsInt()
@@ -127,18 +127,18 @@ export class CreateEventTypeInput_2024_06_14 {
description:
"Locations where the event will take place. If not provided, cal video link will be used as the location.",
oneOf: [
{ $ref: getSchemaPath(AddressLocation_2024_06_14) },
{ $ref: getSchemaPath(LinkLocation_2024_06_14) },
{ $ref: getSchemaPath(IntegrationLocation_2024_06_14) },
{ $ref: getSchemaPath(PhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(AttendeeAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(AttendeePhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(AttendeeDefinedLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(InputLinkLocation_2024_06_14) },
{ $ref: getSchemaPath(InputIntegrationLocation_2024_06_14) },
{ $ref: getSchemaPath(InputPhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAttendeeAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAttendeePhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAttendeeDefinedLocation_2024_06_14) },
],
type: "array",
})
@Type(() => Object)
locations?: Location_2024_06_14[];
locations?: InputLocation_2024_06_14[];
@IsOptional()
@ValidateInputBookingFields_2024_06_14()
@@ -5,7 +5,7 @@ import { IsString, IsUrl, IsIn, IsPhoneNumber, IsBoolean } from "class-validator
import type { ValidationOptions, ValidatorConstraintInterface } from "class-validator";
import { registerDecorator, validate, ValidatorConstraint } from "class-validator";
const locations = [
export const inputLocations = [
"address",
"link",
"integration",
@@ -15,8 +15,8 @@ const locations = [
"attendeeDefined",
] as const;
export class AddressLocation_2024_06_14 {
@IsIn(locations)
export class InputAddressLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({ example: "address", description: "only allowed value for type is `address`" })
type!: "address";
@@ -29,8 +29,8 @@ export class AddressLocation_2024_06_14 {
public!: boolean;
}
export class LinkLocation_2024_06_14 {
@IsIn(locations)
export class InputLinkLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({ example: "link", description: "only allowed value for type is `link`" })
type!: "link";
@@ -43,21 +43,21 @@ export class LinkLocation_2024_06_14 {
public!: boolean;
}
const integrations = ["cal-video"] as const;
export type Integration_2024_06_14 = (typeof integrations)[number];
export const supportedIntegrations = ["cal-video", "google-meet"] as const;
export type Integration_2024_06_14 = (typeof supportedIntegrations)[number];
export class IntegrationLocation_2024_06_14 {
@IsIn(locations)
export class InputIntegrationLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({ example: "integration", description: "only allowed value for type is `integration`" })
type!: "integration";
@IsIn(integrations)
@DocsProperty({ example: integrations[0], enum: integrations })
@IsIn(supportedIntegrations)
@DocsProperty({ example: supportedIntegrations[0], enum: supportedIntegrations })
integration!: Integration_2024_06_14;
}
export class PhoneLocation_2024_06_14 {
@IsIn(locations)
export class InputPhoneLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({ example: "phone", description: "only allowed value for type is `phone`" })
type!: "phone";
@@ -70,22 +70,22 @@ export class PhoneLocation_2024_06_14 {
public!: boolean;
}
export class AttendeeAddressLocation_2024_06_14 {
@IsIn(locations)
export class InputAttendeeAddressLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({
example: "attendeeAddress",
description: "only allowed value for type is `attendeeAddress`",
})
type!: "attendeeAddress";
}
export class AttendeePhoneLocation_2024_06_14 {
@IsIn(locations)
export class InputAttendeePhoneLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({ example: "attendeePhone", description: "only allowed value for type is `attendeePhone`" })
type!: "attendeePhone";
}
export class AttendeeDefinedLocation_2024_06_14 {
@IsIn(locations)
export class InputAttendeeDefinedLocation_2024_06_14 {
@IsIn(inputLocations)
@DocsProperty({
example: "attendeeDefined",
description: "only allowed value for type is `attendeeDefined`",
@@ -93,25 +93,25 @@ export class AttendeeDefinedLocation_2024_06_14 {
type!: "attendeeDefined";
}
export type Location_2024_06_14 =
| AddressLocation_2024_06_14
| LinkLocation_2024_06_14
| IntegrationLocation_2024_06_14
| PhoneLocation_2024_06_14
| AttendeeAddressLocation_2024_06_14
| AttendeePhoneLocation_2024_06_14
| AttendeeDefinedLocation_2024_06_14;
export type InputLocation_2024_06_14 =
| InputAddressLocation_2024_06_14
| InputLinkLocation_2024_06_14
| InputIntegrationLocation_2024_06_14
| InputPhoneLocation_2024_06_14
| InputAttendeeAddressLocation_2024_06_14
| InputAttendeePhoneLocation_2024_06_14
| InputAttendeeDefinedLocation_2024_06_14;
@ValidatorConstraint({ async: true })
class LocationValidator_2024_06_14 implements ValidatorConstraintInterface {
private classTypeMap: { [key: string]: new () => Location_2024_06_14 } = {
address: AddressLocation_2024_06_14,
link: LinkLocation_2024_06_14,
integration: IntegrationLocation_2024_06_14,
phone: PhoneLocation_2024_06_14,
attendeePhone: AttendeePhoneLocation_2024_06_14,
attendeeAddress: AttendeeAddressLocation_2024_06_14,
attendeeDefined: AttendeeDefinedLocation_2024_06_14,
class InputLocationValidator_2024_06_14 implements ValidatorConstraintInterface {
private classTypeMap: { [key: string]: new () => InputLocation_2024_06_14 } = {
address: InputAddressLocation_2024_06_14,
link: InputLinkLocation_2024_06_14,
integration: InputIntegrationLocation_2024_06_14,
phone: InputPhoneLocation_2024_06_14,
attendeePhone: InputAttendeePhoneLocation_2024_06_14,
attendeeAddress: InputAttendeeAddressLocation_2024_06_14,
attendeeDefined: InputAttendeeDefinedLocation_2024_06_14,
};
async validate(locations: { type: string }[]) {
@@ -160,7 +160,7 @@ export function ValidateLocations_2024_06_14(validationOptions?: ValidationOptio
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: new LocationValidator_2024_06_14(),
validator: new InputLocationValidator_2024_06_14(),
});
};
}
@@ -50,24 +50,24 @@ import { DestinationCalendar_2024_06_14 } from "./destination-calendar.input";
import { Disabled_2024_06_14 } from "./disabled.input";
import { EventTypeColor_2024_06_14 } from "./event-type-color.input";
import {
AddressLocation_2024_06_14,
AttendeeAddressLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14,
AttendeePhoneLocation_2024_06_14,
IntegrationLocation_2024_06_14,
LinkLocation_2024_06_14,
PhoneLocation_2024_06_14,
InputAddressLocation_2024_06_14,
InputAttendeeAddressLocation_2024_06_14,
InputAttendeeDefinedLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputIntegrationLocation_2024_06_14,
InputLinkLocation_2024_06_14,
InputPhoneLocation_2024_06_14,
ValidateLocations_2024_06_14,
} from "./locations.input";
import type { Location_2024_06_14 } from "./locations.input";
import type { InputLocation_2024_06_14 } from "./locations.input";
import { Recurrence_2024_06_14 } from "./recurrence.input";
import { Seats_2024_06_14 } from "./seats.input";
@ApiExtraModels(
AddressLocation_2024_06_14,
LinkLocation_2024_06_14,
IntegrationLocation_2024_06_14,
PhoneLocation_2024_06_14,
InputAddressLocation_2024_06_14,
InputLinkLocation_2024_06_14,
InputIntegrationLocation_2024_06_14,
InputPhoneLocation_2024_06_14,
PhoneFieldInput_2024_06_14,
AddressFieldInput_2024_06_14,
TextFieldInput_2024_06_14,
@@ -88,9 +88,9 @@ import { Seats_2024_06_14 } from "./seats.input";
Recurrence_2024_06_14,
BaseConfirmationPolicy_2024_06_14,
Seats_2024_06_14,
AttendeeAddressLocation_2024_06_14,
AttendeePhoneLocation_2024_06_14,
AttendeeDefinedLocation_2024_06_14
InputAttendeeAddressLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputAttendeeDefinedLocation_2024_06_14
)
export class UpdateEventTypeInput_2024_06_14 {
@IsOptional()
@@ -120,18 +120,18 @@ export class UpdateEventTypeInput_2024_06_14 {
description:
"Locations where the event will take place. If not provided, cal video link will be used as the location.",
oneOf: [
{ $ref: getSchemaPath(AddressLocation_2024_06_14) },
{ $ref: getSchemaPath(LinkLocation_2024_06_14) },
{ $ref: getSchemaPath(IntegrationLocation_2024_06_14) },
{ $ref: getSchemaPath(PhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(AttendeeAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(AttendeePhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(AttendeeDefinedLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(InputLinkLocation_2024_06_14) },
{ $ref: getSchemaPath(InputIntegrationLocation_2024_06_14) },
{ $ref: getSchemaPath(InputPhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAttendeeAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAttendeePhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(InputAttendeeDefinedLocation_2024_06_14) },
],
type: "array",
})
@Type(() => Object)
locations?: Location_2024_06_14[];
locations?: InputLocation_2024_06_14[];
@IsOptional()
@ValidateInputBookingFields_2024_06_14()
@@ -205,6 +205,32 @@ export class GuestsDefaultFieldOutput_2024_06_14 {
required!: false;
}
export class PhoneDefaultFieldOutput_2024_06_14 {
@IsBoolean()
@DocsProperty({
description: "This property is always true because it's a default field",
example: true,
default: true,
})
isDefault = true;
@IsString()
@DocsProperty({
default: "attendeePhoneNumber",
})
slug!: "attendeePhoneNumber";
@IsString()
@DocsProperty({
default: "phone",
})
type!: "phone";
@IsBoolean()
@DocsProperty()
required!: boolean;
}
export class PhoneFieldOutput_2024_06_14 extends PhoneFieldInput_2024_06_14 {
@IsBoolean()
@DocsProperty({
@@ -315,6 +341,17 @@ export class BooleanFieldOutput_2024_06_14 extends BooleanFieldInput_2024_06_14
isDefault = false;
}
export class OutputUnknownBookingField_2024_06_14 {
@DocsProperty({ example: "unknown", description: "only allowed value for type is `unknown`" })
type!: "unknown";
@DocsProperty({ example: "unknown", description: "only allowed value for type is `unknown`" })
slug!: "unknown";
@IsString()
bookingField!: string;
}
export type DefaultFieldOutput_2024_06_14 =
| NameDefaultFieldOutput_2024_06_14
| EmailDefaultFieldOutput_2024_06_14
@@ -322,7 +359,8 @@ export type DefaultFieldOutput_2024_06_14 =
| RescheduleReasonDefaultFieldOutput_2024_06_14
| TitleDefaultFieldOutput_2024_06_14
| NotesDefaultFieldOutput_2024_06_14
| GuestsDefaultFieldOutput_2024_06_14;
| GuestsDefaultFieldOutput_2024_06_14
| PhoneDefaultFieldOutput_2024_06_14;
export type CustomFieldOutput_2024_06_14 =
| PhoneFieldOutput_2024_06_14
@@ -337,7 +375,10 @@ export type CustomFieldOutput_2024_06_14 =
| RadioGroupFieldOutput_2024_06_14
| BooleanFieldOutput_2024_06_14;
export type OutputBookingField_2024_06_14 = DefaultFieldOutput_2024_06_14 | CustomFieldOutput_2024_06_14;
export type OutputBookingField_2024_06_14 =
| DefaultFieldOutput_2024_06_14
| CustomFieldOutput_2024_06_14
| OutputUnknownBookingField_2024_06_14;
@ValidatorConstraint({ async: true })
class OutputBookingFieldValidator_2024_06_14 implements ValidatorConstraintInterface {
@@ -349,6 +390,7 @@ class OutputBookingFieldValidator_2024_06_14 implements ValidatorConstraintInter
title: TitleDefaultFieldOutput_2024_06_14,
notes: NotesDefaultFieldOutput_2024_06_14,
guests: GuestsDefaultFieldOutput_2024_06_14,
attendeePhoneNumber: PhoneDefaultFieldOutput_2024_06_14,
};
private customOutputTypeMap: { [key: string]: new () => CustomFieldOutput_2024_06_14 } = {
@@ -390,9 +432,13 @@ class OutputBookingFieldValidator_2024_06_14 implements ValidatorConstraintInter
`Duplicate bookingFields slug '${slug}' found. All bookingFields slugs must be unique.`
);
}
slugs.push(slug);
if (slug !== "unknown") {
slugs.push(slug);
}
if (this.isDefaultField(field)) {
if (this.isUnknownField(field)) {
await this.validateUnknownField(field);
} else if (this.isDefaultField(field)) {
await this.validateDefaultField(field);
} else {
await this.validateCustomField(field);
@@ -403,13 +449,26 @@ class OutputBookingFieldValidator_2024_06_14 implements ValidatorConstraintInter
}
isDefaultField(field: OutputBookingField_2024_06_14): field is DefaultFieldOutput_2024_06_14 {
return field.isDefault === true;
return field.type !== "unknown" && "isDefault" in field && field.isDefault === true;
}
isUnknownField(field: OutputBookingField_2024_06_14): field is OutputUnknownBookingField_2024_06_14 {
return field.type === "unknown";
}
async validateUnknownField(field: OutputUnknownBookingField_2024_06_14) {
const instance = plainToInstance(OutputUnknownBookingField_2024_06_14, field);
const errors = await validate(instance);
if (errors.length > 0) {
const message = errors.flatMap((error) => Object.values(error.constraints || {})).join(", ");
throw new BadRequestException(`Validation failed for ${field.slug} booking field: ${message}`);
}
}
async validateDefaultField(field: DefaultFieldOutput_2024_06_14) {
const ClassType = this.defaultOutputNameMap[field.slug];
if (!ClassType) {
throw new BadRequestException(`Unsupported booking field slgu '${field.slug}'.`);
throw new BadRequestException(`Unsupported default booking field slug '${field.slug}'.`);
}
const instance = plainToInstance(ClassType, field);
@@ -11,11 +11,7 @@ import {
ValidateNested,
} from "class-validator";
import type {
Location_2024_06_14,
BookingWindow_2024_06_14,
BookingLimitsDuration_2024_06_14,
} from "../inputs";
import type { BookingWindow_2024_06_14, BookingLimitsDuration_2024_06_14 } from "../inputs";
import {
EventTypeColor_2024_06_14,
Seats_2024_06_14,
@@ -30,13 +26,6 @@ import { BookerLayouts_2024_06_14 } from "../inputs/booker-layouts.input";
import type { BookingLimitsCount_2024_06_14 } from "../inputs/booking-limits-count.input";
import type { ConfirmationPolicy_2024_06_14 } from "../inputs/confirmation-policy.input";
import { DestinationCalendar_2024_06_14 } from "../inputs/destination-calendar.input";
import {
AddressLocation_2024_06_14,
IntegrationLocation_2024_06_14,
LinkLocation_2024_06_14,
PhoneLocation_2024_06_14,
ValidateLocations_2024_06_14,
} from "../inputs/locations.input";
import {
EmailDefaultFieldOutput_2024_06_14,
NameDefaultFieldOutput_2024_06_14,
@@ -59,6 +48,16 @@ import {
} from "../outputs/booking-fields.output";
import type { OutputBookingField_2024_06_14 } from "./booking-fields.output";
import { ValidateOutputBookingFields_2024_06_14 } from "./booking-fields.output";
import type { OutputLocation_2024_06_14 } from "./locations.output";
import {
OutputAddressLocation_2024_06_14,
OutputConferencingLocation_2024_06_14,
OutputIntegrationLocation_2024_06_14,
OutputLinkLocation_2024_06_14,
OutputPhoneLocation_2024_06_14,
OutputUnknownLocation_2024_06_14,
ValidateOutputLocations_2024_06_14,
} from "./locations.output";
enum SchedulingTypeEnum {
ROUND_ROBIN = "ROUND_ROBIN",
@@ -95,10 +94,12 @@ class User_2024_06_14 {
}
@ApiExtraModels(
AddressLocation_2024_06_14,
LinkLocation_2024_06_14,
IntegrationLocation_2024_06_14,
PhoneLocation_2024_06_14,
OutputAddressLocation_2024_06_14,
OutputLinkLocation_2024_06_14,
OutputIntegrationLocation_2024_06_14,
OutputPhoneLocation_2024_06_14,
OutputConferencingLocation_2024_06_14,
OutputUnknownLocation_2024_06_14,
EmailDefaultFieldOutput_2024_06_14,
NameDefaultFieldOutput_2024_06_14,
LocationDefaultFieldOutput_2024_06_14,
@@ -146,18 +147,20 @@ class BaseEventTypeOutput_2024_06_14 {
})
description!: string;
@ValidateLocations_2024_06_14()
@ValidateOutputLocations_2024_06_14()
@DocsProperty({
oneOf: [
{ $ref: getSchemaPath(AddressLocation_2024_06_14) },
{ $ref: getSchemaPath(LinkLocation_2024_06_14) },
{ $ref: getSchemaPath(IntegrationLocation_2024_06_14) },
{ $ref: getSchemaPath(PhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(OutputAddressLocation_2024_06_14) },
{ $ref: getSchemaPath(OutputLinkLocation_2024_06_14) },
{ $ref: getSchemaPath(OutputIntegrationLocation_2024_06_14) },
{ $ref: getSchemaPath(OutputPhoneLocation_2024_06_14) },
{ $ref: getSchemaPath(OutputConferencingLocation_2024_06_14) },
{ $ref: getSchemaPath(OutputUnknownLocation_2024_06_14) },
],
type: "array",
})
@Type(() => Object)
locations!: Location_2024_06_14[];
locations!: OutputLocation_2024_06_14[];
@ValidateOutputBookingFields_2024_06_14()
@DocsProperty()
@@ -1,2 +1,3 @@
export * from "./event-type.output";
export * from "./booking-fields.output";
export * from "./locations.output";
@@ -0,0 +1,167 @@
import { BadRequestException } from "@nestjs/common";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { IsUrl, IsIn, IsOptional, IsNumber, IsString } from "class-validator";
import type { ValidationOptions, ValidatorConstraintInterface } from "class-validator";
import { registerDecorator, validate, ValidatorConstraint } from "class-validator";
import {
InputAddressLocation_2024_06_14,
InputAttendeeAddressLocation_2024_06_14,
InputAttendeeDefinedLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputLinkLocation_2024_06_14,
inputLocations,
InputPhoneLocation_2024_06_14,
} from "../inputs";
const outputLocations = [...inputLocations, "conferencing", "unknown"] as const;
export class OutputAddressLocation_2024_06_14 extends InputAddressLocation_2024_06_14 {}
export class OutputLinkLocation_2024_06_14 extends InputLinkLocation_2024_06_14 {}
export class OutputPhoneLocation_2024_06_14 extends InputPhoneLocation_2024_06_14 {}
export class OutputAttendeeAddressLocation_2024_06_14 extends InputAttendeeAddressLocation_2024_06_14 {}
export class OutputAttendeePhoneLocation_2024_06_14 extends InputAttendeePhoneLocation_2024_06_14 {}
export class OutputAttendeeDefinedLocation_2024_06_14 extends InputAttendeeDefinedLocation_2024_06_14 {}
const integrationsValues = [
"cal-video",
"google-meet",
"zoom",
"whereby-video",
"whatsapp-video",
"webex-video",
"telegram-video",
"tandem",
"sylaps-video",
"skype-video",
"sirius-video",
"signal-video",
"shimmer-video",
"salesroom-video",
"roam-video",
"riverside-video",
"ping-video",
"office365-video",
"mirotalk-video",
"jitsi",
"jelly-video",
"jelly-conferencing",
"huddle",
"facetime-video",
"element-call-video",
"eightxeight-video",
"discord-video",
"demodesk-video",
"campsite-conferencing",
"campfire-video",
"around-video",
] as const;
export type OutputIntegration_2024_06_14 = (typeof integrationsValues)[number];
export class OutputIntegrationLocation_2024_06_14 {
@IsIn(outputLocations)
@DocsProperty({ example: "integration", description: "only allowed value for type is `integration`" })
type!: "integration";
@IsIn(integrationsValues)
@DocsProperty({ example: integrationsValues[0], enum: integrationsValues })
integration!: OutputIntegration_2024_06_14;
@IsUrl()
@IsOptional()
link?: string;
@IsNumber()
@IsOptional()
credentialId?: number;
}
export class OutputConferencingLocation_2024_06_14 {
@IsIn(outputLocations)
@DocsProperty({ example: "conferencing", description: "only allowed value for type is `conferencing`" })
type!: "conferencing";
}
export class OutputUnknownLocation_2024_06_14 {
@IsIn(outputLocations)
@DocsProperty({ example: "unknown", description: "only allowed value for type is `unknown`" })
type!: "unknown";
@IsString()
location!: string;
}
export type OutputLocation_2024_06_14 =
| OutputAddressLocation_2024_06_14
| OutputLinkLocation_2024_06_14
| OutputIntegrationLocation_2024_06_14
| OutputPhoneLocation_2024_06_14
| OutputAttendeeAddressLocation_2024_06_14
| OutputAttendeePhoneLocation_2024_06_14
| OutputAttendeeDefinedLocation_2024_06_14
| OutputConferencingLocation_2024_06_14
| OutputUnknownLocation_2024_06_14;
@ValidatorConstraint({ async: true })
class OutputLocationValidator_2024_06_14 implements ValidatorConstraintInterface {
private classTypeMap: { [key: string]: new () => OutputLocation_2024_06_14 } = {
address: OutputAddressLocation_2024_06_14,
link: OutputLinkLocation_2024_06_14,
integration: OutputIntegrationLocation_2024_06_14,
phone: OutputPhoneLocation_2024_06_14,
attendeePhone: OutputAttendeePhoneLocation_2024_06_14,
attendeeAddress: OutputAttendeeAddressLocation_2024_06_14,
attendeeDefined: OutputAttendeeDefinedLocation_2024_06_14,
conferencing: OutputConferencingLocation_2024_06_14,
unknown: OutputUnknownLocation_2024_06_14,
};
async validate(locations: { type: string }[]) {
if (!Array.isArray(locations)) {
throw new BadRequestException(`'locations' must be an array.`);
}
if (!locations.length) {
throw new BadRequestException(`'locations' must contain at least 1 location.`);
}
for (const location of locations) {
const { type } = location;
if (!type) {
throw new BadRequestException(`Each object in 'locations' must have a 'type' property.`);
}
const ClassType = this.classTypeMap[type];
if (!ClassType) {
throw new BadRequestException(`Unsupported output type '${type}'.`);
}
const instance = plainToInstance(ClassType, location);
const errors = await validate(instance);
if (errors.length > 0) {
const message = errors.flatMap((error) => Object.values(error.constraints || {})).join(", ");
throw new BadRequestException(`Validation failed for ${type} location: ${message}`);
}
}
return true;
}
defaultMessage() {
return `Validation failed for one or more location entries.`;
}
}
export function ValidateOutputLocations_2024_06_14(validationOptions?: ValidationOptions) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (object: any, propertyName: string) {
registerDecorator({
name: "ValidateLocation",
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: new OutputLocationValidator_2024_06_14(),
});
};
}
@@ -7,7 +7,7 @@ import type {
transformRecurrenceApiToInternal,
transformSeatsApiToInternal,
transformBookingFieldsApiToInternal,
TransformedLocationsSchema,
InternalLocationsSchema,
} from "@calcom/lib/event-types/transformers";
import type { CreateEventTypeInput_2024_06_14, ConfirmationPolicyTransformedSchema } from "../inputs";
@@ -32,7 +32,7 @@ export type InputEventTransformed_2024_06_14 = Omit<
slug: string;
eventName?: string;
bookingLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
locations?: z.infer<typeof TransformedLocationsSchema>;
locations?: z.infer<typeof InternalLocationsSchema>;
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;

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