Compare commits

...
16 Commits
Author SHA1 Message Date
Keith Williams 685eadc30b chore: v4.4.3 (#16321)
release.yml / release (push) Canceled after 0s
2024-08-22 14:28:17 +00:00
Udit TakkarandHariom Balhara c821f15a14 fix: error message when email exists (#16022)
* fix: error message when email exists

* chore: change postion of error

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-08-22 14:20:41 +00:00
Morgan 8a972a74c6 refactor: bulk update modal logic shifted to web wrapper (#16315) 2024-08-22 13:42:43 +00:00
cf76cf7aae feat: Multiple Options Paste and Routing Form improvements related to Select fields (#15706)
* Add handlePaste function and integrate it with options fields

* Prevents function running if user does not paste a list, allows list items to be pasted without deleting following items

* Subs useState for rhf useWatch to fix issues with multiple form fields

* Sets options array as optional property in zod schema

* Assigns default value to options array to fix possibly undefined type errors

* Sets placedholder property as optional

* maps selectText to options on mount

* Removes comma & semicolon delimiting for pasted lists (new line only) & defines regex as a constant

* Sets default value of placeholder to empty string, provides default fallback for component text field if placeholder is omitted.

* Fix type error on placeholder

* Extracts handlePaste to service function & tests thoroughly

* Some improvements and tests arent needed

* More fixes

* Fix reporting as well

* TS fixes

* Do transforms to and fro from label

* Add unit tests

* Unit test reporting

* Test Improvements

* Self review feedback addressed

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-22 13:38:46 +00:00
Keith Williams 4dd762681c chore: Update how E2E test suite checks for Stripe (#16319) 2024-08-22 13:19:50 +00:00
Joe Au-YeungandCarina Wollendorfer 399a23a52f fix: Disable ICS file generation when destination calendar is Outlook (#16247)
* Set the organizer in the organizer field

* Create generateIcsFile

* Add role enum and only disable for organizers

* Move emails to generateIcsFile

* Add new ics func to new emails

* Fix tests

* Type fix

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-08-22 09:49:53 +00:00
Udit Takkar 342f935aba fix: create event type refresh bug (#16301)
* fix: create event type refresh bug

* chore

* fix: type err
2024-08-22 09:43:09 +00:00
27e4210693 fix: atoms icons and build issues (#16248)
* fix: atoms icons and build issues

* fixup! fix: atoms icons and build issues

* fixup! fixup! fix: atoms icons and build issues

* fix import paths

* rename SchedulingType to something more specific

* fix: Invalid define value (must be an entity name or valid JSON syntax)

* chore: atoms build CI job

* chore: enable atoms build in CI

* fix build

* fixup! fix build

* fixup! fixup! fix build

* chore: replace calendar-days icon with calendar

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2024-08-22 08:32:39 +00:00
Amit SharmaandUdit Takkar 66cb2bcc13 fix: Workflow action 'email to host' only sends to one host of team e… (#14767)
* fix: Workflow action 'email to host' only sends to one host of team event

* add in missing files

* fix host logic

* uncomment working test

* code refactor

* use connected calendar email

* add changes to util file

* fix: extend eventType info in sendCancelledReminders

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-08-22 08:04:31 +00:00
Akash RajpurohitandAnik Dhabal Babu 30c31d3585 feat: ✨ support for adding script url added for umami (#16300)
* feat: ✨ support for adding script url added for umami

This will help self hosters in setting up umami.

* feat: ✨ set default value for script URL in the form field

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-22 09:39:03 +02:00
Omar López e17303ea88 fix: permissions issue in pr labeler (#16245) 2024-08-22 01:22:07 +01:00
Anik Dhabal BabuandAmit Sharma bebf1d80a7 fix: round robin event rejection problem (#16279)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-21 17:07:30 +00:00
Benny Joo 5bcefb0b3e settings/teams/[id]/sso is not needed (#16288) 2024-08-21 16:47:34 +00:00
Harshit SharmaandAnik Dhabal Babu f52de7331f fix: UI Breaks When Entering a Long Username in Profile Settings (#16304)
* feat: Improve UI for username availability in UsernameTextfield component

* fix: Fixed UI for username availability in UsernameTextfield component

* Update UsernameTextfield.tsx

* fix: FixedUI for username availability in PremiumTextfield and UsernameTextfield components

* fix: Fixed the PremiumTextField.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-21 16:17:14 +00:00
MorganandLauris Skraucis 1690770d6a fix: check profiles and orgId from user.profiles apiv2 (#16249)
* fix: check profiles and orgId from user.profiles apiv2

* fixup! fix: check profiles and orgId from user.profiles apiv2

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-08-21 15:15:48 +00:00
Rajiv Sahal 7759e6a029 chore: update platform billing plans (#16233)
* update links for no platform plan

* sync platform pricing with one we have in website
2024-08-21 20:14:15 +05:30
120 changed files with 2062 additions and 594 deletions
+5
View File
@@ -34,6 +34,11 @@ jobs:
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build-atoms:
name: Production builds
uses: ./.github/workflows/atoms-production-build.yml
secrets: inherit
build:
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
@@ -0,0 +1,40 @@
name: Atoms production Build
on:
workflow_call:
jobs:
build:
name: Build atoms
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Cache atoms production build
uses: buildjet/cache@v4
id: cache-atoms-build
env:
cache-name: atoms-build
key-1: ${{ hashFiles('yarn.lock') }}
key-2: ${{ hashFiles('packages/platform/atoms/**.[jt]s', 'packages/platform/atoms/**.[jt]sx', '!**/node_modules') }}
key-3: ${{ github.event.pull_request.number || github.ref }}
# Ensures production-build.yml will always be fresh
key-4: ${{ github.sha }}
with:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- 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
shell: bash
+9 -1
View File
@@ -14,7 +14,15 @@ jobs:
steps:
- name: Label PR as ready for E2E
if: github.event.review.state == 'approved'
uses: actions-ecosystem/action-add-labels@v1
uses: actions/github-script@v4
with:
script: |
await github.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: process.env.labels.split(', '),
})
env:
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
labels: 'ready-for-e2e'
+7
View File
@@ -131,6 +131,13 @@ jobs:
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build-atoms:
name: Production builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/atoms-production-build.yml
secrets: inherit
build:
name: Production builds
+2 -1
View File
@@ -10,7 +10,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-enums build",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
@@ -25,6 +25,7 @@
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
@@ -1,6 +1,7 @@
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -9,7 +10,11 @@ import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class EventTypesRepository_2024_04_15 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
async createUserEventType(
userId: number,
@@ -46,7 +51,7 @@ export class EventTypesRepository_2024_04_15 {
eventTypeId: number
) {
return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
@@ -6,6 +6,7 @@ import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
@@ -24,7 +25,8 @@ export class EventTypesService_2024_04_15 {
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
async createUserEventType(
@@ -53,11 +55,11 @@ export class EventTypesService_2024_04_15 {
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
return {
id: user.id,
role: user.role,
@@ -80,7 +82,7 @@ export class EventTypesService_2024_04_15 {
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
@@ -153,7 +155,7 @@ export class EventTypesService_2024_04_15 {
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
@@ -20,13 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/dist/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { SchedulingType } from "@calcom/prisma/enums";
describe("Event types Endpoints", () => {
@@ -1,5 +1,6 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -34,7 +35,11 @@ type InputEventTransformed = Omit<
@Injectable()
export class EventTypesRepository_2024_06_14 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
async createUserEventType(userId: number, body: InputEventTransformed) {
return this.dbWrite.prisma.eventType.create({
@@ -80,7 +85,7 @@ export class EventTypesRepository_2024_06_14 {
eventTypeId: number
) {
return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
@@ -119,11 +119,11 @@ export class EventTypesService_2024_06_14 {
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return {
id: user.id,
@@ -159,7 +159,7 @@ export class EventTypesService_2024_06_14 {
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
@@ -270,7 +270,7 @@ export class EventTypesService_2024_06_14 {
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
@@ -1,6 +1,7 @@
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
@@ -12,13 +13,14 @@ export class OrganizationRolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private organizationsService: OrganizationsService,
private membershipRepository: MembershipsRepository
private membershipRepository: MembershipsRepository,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user: UserWithProfile = request.user;
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
if (!user || !organizationId) {
throw new ForbiddenException("No organization associated with the user.");
@@ -7,6 +7,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
duration: 1000,
},
}),
UsersModule,
],
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
exports: [BillingService, BillingRepository],
@@ -13,6 +13,7 @@ import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-gu
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
@@ -59,7 +60,8 @@ export class OAuthClientsController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
private readonly teamsRepository: OrganizationsRepository,
private usersService: UsersService
) {}
@Post("/")
@@ -74,7 +76,7 @@ export class OAuthClientsController {
@GetUser() user: UserWithProfile,
@Body() body: CreateOAuthClientInput
): Promise<CreateOAuthClientResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
const organizationId = this.usersService.getUserMainOrgId(user) as number;
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
@@ -100,7 +102,7 @@ export class OAuthClientsController {
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
const organizationId = this.usersService.getUserMainOrgId(user) as number;
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
return { status: SUCCESS_STATUS, data: clients };
@@ -1,5 +1,6 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import {
Injectable,
CanActivate,
@@ -11,12 +12,12 @@ import { Request } from "express";
@Injectable()
export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository) {}
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
const user: GetUserReturnType = request.user;
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
const oAuthClientId = request.params.clientId;
if (!oAuthClientId) {
@@ -17,6 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
@@ -24,7 +25,6 @@ import {
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { Team } from "@calcom/prisma/client";
describe("Organizations Event Types Endpoints", () => {
@@ -8,8 +8,8 @@ import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
HostPriority,
SchedulingType,
} from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/client";
@Injectable()
export class InputOrganizationsEventTypesService {
@@ -96,7 +96,8 @@ export class OrganizationsEventTypesService {
async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId);
const profileId =
this.usersService.getUserProfileByOrgId(user, organizationId)?.id || user.movedToProfileId;
this.usersService.getUserProfileByOrgId(user, organizationId)?.id ||
this.usersService.getUserMainProfile(user)?.id;
return {
id: user.id,
role: user.role,
@@ -30,6 +30,10 @@ export class UsersService {
);
}
getUserMainOrgId(user: UserWithProfile) {
return this.getUserMainProfile(user)?.organizationId ?? user.organizationId;
}
getUserProfileByOrgId(user: UserWithProfile, organizationId: number) {
return user.profiles?.find((p) => p.organizationId === organizationId);
}
@@ -1,5 +1,6 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
CanActivate,
@@ -17,7 +18,8 @@ import { PlatformOAuthClient, Webhook } from "@calcom/prisma/client";
export class IsOAuthClientWebhookGuard implements CanActivate {
constructor(
private readonly webhooksService: WebhooksService,
private readonly oAuthClientRepository: OAuthClientRepository
private readonly oAuthClientRepository: OAuthClientRepository,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,7 +29,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
const user = request.user as GetUserReturnType;
const webhookId = request.params.webhookId;
const oAuthClientId = request.params.clientId;
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
if (!user) {
throw new ForbiddenException("User not authenticated");
+6
View File
@@ -4955,6 +4955,9 @@
"items": {
"$ref": "#/components/schemas/Source"
}
},
"disableOnPrefill": {
"type": "boolean"
}
},
"required": [
@@ -5529,6 +5532,9 @@
"items": {
"$ref": "#/components/schemas/Source"
}
},
"disableOnPrefill": {
"type": "boolean"
}
},
"required": [
@@ -1,11 +0,0 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/sso/page/teams-sso-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("sso_configuration"),
(t) => t("sso_configuration_description")
);
export default Page;
@@ -8,8 +8,24 @@ export default function NoPlatformPlan() {
description="You are not subscribed to a Platform plan."
buttonRaw={
<div className="flex gap-2">
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
<Button color="secondary" href="https://cal.com/pricing">
<Button
className="hover:bg-slate-300 hover:text-black"
color="secondary"
href="/settings/platform/new">
Subscribe
</Button>
<Button
color="secondary"
className="hover:bg-slate-300 hover:text-black"
href="https://cal.com/platform/pricing"
target="_blank">
Go to Pricing
</Button>
<Button
color="secondary"
className="hover:bg-slate-300 hover:text-black"
href="https://cal.com/sales"
target="_blank">
Contact Sales
</Button>
</div>
@@ -14,10 +14,10 @@ export const platformPlans: IndividualPlatformPlan[] = [
pricing: 99,
includes: [
"Up to 100 bookings a month",
"$0.99 overage beyond",
"Community Support",
"Cal Atoms (React Library)",
"Platform APIs",
"Admin APIs",
],
},
{
@@ -26,9 +26,9 @@ export const platformPlans: IndividualPlatformPlan[] = [
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 299,
includes: [
"Up to 500 bookings a month. $0,60 overage beyond",
"Everything in Starter",
"Cal Atoms (React Library)",
"Up to 500 bookings a month",
"$0.60 overage beyond",
"User Management and Analytics",
"Technical Account Manager and Onboarding Support",
],
@@ -39,8 +39,9 @@ export const platformPlans: IndividualPlatformPlan[] = [
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
pricing: 2499,
includes: [
"Up to 5000 bookings a month. $0.50 overage beyond",
"Everything in Essentials",
"Up to 5000 bookings a month",
"$0.50 overage beyond",
"Credential import from other platforms",
"Compliance Check SOC2, HIPAA",
"One-on-one developer calls",
@@ -51,7 +52,18 @@ export const platformPlans: IndividualPlatformPlan[] = [
},
{
plan: "Enterprise",
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
description:
"Enterprise is perfect for institutions that focus on control, security and everything beyond.",
includes: [
"Everything in Scale",
"No overages",
"Credential import from other platforms",
"Compliance Check SOC2, HIPAA",
"One-on-one developer calls",
"Help with Credentials Verification (Zoom, Google App Store)",
"Expedited features and integrations",
"SLA (99.999% uptime)",
"Volume Discount",
],
},
];
@@ -280,7 +280,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
<div className="flex-1 px-2">
<p className="text-subtle">{t("current_username")}</p>
<p className="text-emphasis mt-1" data-testid="current-username">
<p className="text-emphasis mt-1 break-all" data-testid="current-username">
{currentUsername}
</p>
</div>
@@ -288,7 +288,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
<p className="text-subtle" data-testid="new-username">
{t("new_username")}
</p>
<p className="text-emphasis">{inputUsernameValue}</p>
<p className="text-emphasis break-all">{inputUsernameValue}</p>
</div>
</div>
</div>
@@ -164,7 +164,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
<p className="text-subtle">{t("current_username")}</p>
<Tooltip content={currentUsername}>
<p
className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis"
className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis break-all"
data-testid="current-username">
{currentUsername}
</p>
@@ -175,7 +175,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
{t("new_username")}
</p>
<Tooltip content={inputUsernameValue}>
<p className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis">
<p className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis break-all">
{inputUsernameValue}
</p>
</Tooltip>
@@ -741,7 +741,7 @@ export default function Success(props: PageProps) {
/>
</>
))}
{userIsOwner && !isCancelled && isRejectionMode && (
{!isCancelled && isRejectionMode && (
<>
<hr className="border-subtle" />
<RejectBooking
@@ -170,7 +170,8 @@ const InfiniteMobileTeamsTab: FC<InfiniteMobileTeamsTabProps> = (props) => {
},
{
refetchOnWindowFocus: true,
staleTime: 1 * 60 * 60 * 1000,
refetchOnMount: true,
staleTime: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.4.2",
"version": "4.4.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -36,6 +36,7 @@
"@calcom/embed-snippet": "workspace:*",
"@calcom/features": "*",
"@calcom/lib": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-types": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
+8 -9
View File
@@ -321,15 +321,6 @@ export default function Signup({
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
{/* Left side */}
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
{/* Header */}
{errors.apiError && (
<Alert
className="mb-3"
severity="error"
message={errors.apiError?.message}
data-testid="signup-error-message"
/>
)}
<div className="flex flex-col gap-2">
<h1 className="font-cal text-[28px] leading-none ">
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
@@ -408,6 +399,14 @@ export default function Signup({
onChange={() => handleConsentChange(COOKIE_CONSENT)}
description={t("cookie_consent_checkbox")}
/>
{errors.apiError && (
<Alert
className="mb-3"
severity="error"
message={errors.apiError?.message}
data-testid="signup-error-message"
/>
)}
<Button
type="submit"
className="my-2 w-full justify-center"
+1 -6
View File
@@ -7,15 +7,10 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { moveUserToOrg } from "@lib/orgMigration";
import { test } from "./lib/fixtures";
import { IS_STRIPE_ENABLED } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
const IS_SELF_HOSTED = !(
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com")
);
@@ -33,6 +33,10 @@ export const createRoutingFormsFixture = () => {
label: string;
identifier?: string;
required: boolean;
options?: {
label: string;
id: string | null;
}[];
}[];
}) {
return await prisma.app_RoutingForms_Form.create({
+45 -4
View File
@@ -301,6 +301,10 @@ export const createUsersFixture = (
}
if (scenario.seedRoutingForms) {
const option2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
const option1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
await prisma.app_RoutingForms_Form.create({
data: {
routes: [
@@ -366,6 +370,26 @@ export const createUsersFixture = (
},
{
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectLegacyFieldUuid,
value: [["Option-2"]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
@@ -374,8 +398,8 @@ export const createUsersFixture = (
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: "d4292635-9f12-17b1-9153-c3a854649182",
value: [["Option-2"]],
field: multiSelectFieldUuid,
value: [[option2Uuid]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
@@ -399,13 +423,30 @@ export const createUsersFixture = (
required: true,
},
{
id: "d4292635-9f12-17b1-9153-c3a854649182",
id: multiSelectLegacyFieldUuid,
type: "multiselect",
label: "Multi Select",
label: "Multi Select(with Legacy `selectText`)",
identifier: "multi",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: multiSelectFieldUuid,
type: "multiselect",
label: "Multi Select",
identifier: "multi-new-format",
options: [
{
id: option1Uuid,
label: "Option-1",
},
{
id: option2Uuid,
label: "Option-2",
},
],
required: false,
},
],
user: {
connect: {
@@ -7,19 +7,11 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { test, todo } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
import { IS_STRIPE_ENABLED, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
const IS_STRIPE_ENABLED = !!(
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_CLIENT_ID &&
process.env.STRIPE_PRIVATE_KEY &&
process.env.PAYMENT_FEE_FIXED &&
process.env.PAYMENT_FEE_PERCENTAGE
);
test.describe("Stripe integration skip true", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
+8
View File
@@ -26,6 +26,14 @@ export const testName = "Test Testson";
export const teamEventTitle = "Team Event - 30min";
export const teamEventSlug = "team-event-30min";
export const IS_STRIPE_ENABLED = !!(
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_CLIENT_ID &&
process.env.STRIPE_PRIVATE_KEY &&
process.env.PAYMENT_FEE_FIXED &&
process.env.PAYMENT_FEE_PERCENTAGE
);
export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) {
const {
requestHandler = ({ res }) => {
+4 -6
View File
@@ -12,14 +12,9 @@ import {
bookTimeSlot,
doOnOrgDomain,
goToUrlWithErrorHandling,
IS_STRIPE_ENABLED,
} from "./lib/testUtils";
const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
);
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
@@ -164,6 +159,9 @@ test.describe("Reschedule Tests", async () => {
});
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed");
const user = await users.create();
await user.apiLogin();
await user.installStripePersonal({ skip: true });
+35 -2
View File
@@ -647,6 +647,7 @@ test.describe("FORM_SUBMITTED", async () => {
responses: {
name: {
value: "John Doe",
response: "John Doe",
},
},
},
@@ -673,14 +674,33 @@ test.describe("FORM_SUBMITTED", async () => {
identifier: "name",
required: true,
},
{
type: "multiselect",
label: "Multi Select",
identifier: "multi",
required: true,
options: [
{
label: "Option-1",
id: "1",
},
{
label: "Option-2",
id: "2",
},
],
},
],
});
await page.waitForLoadState("networkidle");
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
const textFieldIdentifier = "name";
const multiSelectFieldIdentifier = "multi";
await page.fill(`[data-testid="form-field-${textFieldIdentifier}"]`, "John Doe");
await page.click(`[data-testid="form-field-${multiSelectFieldIdentifier}"]`); // Open dropdown
await page.click("text=Option-2"); // Select option
page.click('button[type="submit"]');
await webhookReceiver.waitForRequestCount(1);
@@ -699,10 +719,23 @@ test.describe("FORM_SUBMITTED", async () => {
responses: {
name: {
value: "John Doe",
response: "John Doe",
},
multi: {
value: ["Option-2"],
response: [
{
id: "2",
label: "Option-2",
},
],
},
},
},
/* Legacy fields Start */
name: "John Doe",
multi: ["Option-2"],
/* Legacy fields End */
});
webhookReceiver.close();
+12
View File
@@ -92,6 +92,18 @@
<path d="M3 10h18" ></path>
<path d="m16 20 2 2 4-4" ></path>
</symbol>
<symbol class="lucide lucide-calendar-days" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="calendar-days">
<path d="M8 2v4" ></path>
<path d="M16 2v4" ></path>
<rect width="18" height="18" x="3" y="4" rx="2" ></rect>
<path d="M3 10h18" ></path>
<path d="M8 14h.01" ></path>
<path d="M12 14h.01" ></path>
<path d="M16 14h.01" ></path>
<path d="M8 18h.01" ></path>
<path d="M12 18h.01" ></path>
<path d="M16 18h.01" ></path>
</symbol>
<symbol class="lucide lucide-calendar-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="calendar-heart">
<path d="M3 10h18V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7" ></path>
<path d="M8 2v4" ></path>

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+16 -4
View File
@@ -81,15 +81,24 @@ declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveEmail(expectedEmail: ExpectedEmail, to: string): R;
toHaveEmail(expectedEmail: ExpectedEmail, to: string, subject?: string): R;
}
}
}
expect.extend({
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string) {
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string, subject?: string) {
const { isNot } = this;
const testEmail = emails.get().find((email) => email.to.includes(to));
const testEmail = emails.get().find((email) => {
const filterConditions = [email.to.includes(to)];
if (subject) {
filterConditions.push(email.subject === subject);
}
return filterConditions.every((condition) => condition);
});
const emailsToLog = emails
.get()
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
@@ -742,10 +751,12 @@ export function expectSuccessfulBookingRescheduledEmails({
export function expectAwaitingPaymentEmails({
emails,
booker,
subject,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
subject?: string;
}) {
expect(emails).toHaveEmail(
{
@@ -753,7 +764,8 @@ export function expectAwaitingPaymentEmails({
to: `${booker.name} <${booker.email}>`,
noIcs: true,
},
`${booker.email}`
`${booker.email}`,
subject
);
}
@@ -277,17 +277,16 @@ export default class Office365CalendarService implements Calendar {
dateTime: dayjs(event.endTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
timeZone: event.organizer.timeZone,
},
attendees: [
// Add the calEvent organizer
{
emailAddress: {
address: event.destinationCalendar
? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ??
event.organizer.email
: event.organizer.email,
name: event.organizer.name,
},
organizer: {
emailAddress: {
address: event.destinationCalendar
? event.destinationCalendar.find((cal) => cal.userId === event.organizer.id)?.externalId ??
event.organizer.email
: event.organizer.email,
name: event.organizer.name,
},
},
attendees: [
...event.attendees.map((attendee) => ({
emailAddress: {
address: attendee.email,
@@ -6,7 +6,7 @@ import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermis
import prisma from "@calcom/prisma";
import { getSerializableForm } from "../../lib/getSerializableForm";
import type { Response, SerializableForm } from "../../types/types";
import type { FormResponse, SerializableForm } from "../../types/types";
function escapeCsvText(str: string) {
return str.replace(/,/, "%2C");
@@ -32,7 +32,7 @@ async function* getResponses(
) {
const csv: string[] = [];
responses.forEach((response) => {
const fieldResponses = response.response as Response;
const fieldResponses = response.response as FormResponse;
const csvCells: string[] = [];
headerFields.forEach((headerField) => {
const fieldResponse = fieldResponses[headerField.id];
@@ -4,13 +4,14 @@ import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import transformResponse from "../lib/transformResponse";
import type { SerializableForm, Response } from "../types/types";
import { getUIOptionsForSelect } from "../lib/selectOptions";
import { getFieldResponseForJsonLogic } from "../lib/transformResponse";
import type { SerializableForm, FormResponse } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;
response: Response;
setResponse: Dispatch<SetStateAction<Response>>;
response: FormResponse;
setResponse: Dispatch<SetStateAction<FormResponse>>;
};
export default function FormInputFields(props: Props) {
@@ -31,14 +32,7 @@ export default function FormInputFields(props: Props) {
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
const options = getUIOptionsForSelect(field);
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
@@ -62,7 +56,7 @@ export default function FormInputFields(props: Props) {
...response,
[field.id]: {
label: field.label,
value: transformResponse({ field, value }),
value: getFieldResponseForJsonLogic({ field, value }),
},
};
});
@@ -36,7 +36,7 @@ import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
import { RoutingPages } from "../lib/RoutingPages";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { processRoute } from "../lib/processRoute";
import type { Response, Route, SerializableForm } from "../types/types";
import type { FormResponse, Route, SerializableForm } from "../types/types";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar";
@@ -238,7 +238,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
const { t } = useLocale();
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<Response>({});
const [response, setResponse] = useState<FormResponse>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [eventTypeUrl, setEventTypeUrl] = useState("");
@@ -1,6 +1,7 @@
import { FieldTypes } from "../pages/form-edit/[...appPages]";
import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types";
import { InitialConfig } from "./InitialConfig";
import { getUIOptionsForSelect } from "./selectOptions";
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
const fields: Record<
@@ -22,14 +23,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
field = field.routerField;
}
if (FieldTypes.map((f) => f.value).includes(field.type)) {
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
const options = getUIOptionsForSelect(field);
const widget = InitialConfig.widgets[field.type];
const widgetType = widget.type;
@@ -10,6 +10,7 @@ import { zodFields, zodRoutes } from "../zod";
import getConnectedForms from "./getConnectedForms";
import isRouter from "./isRouter";
import isRouterLinkedField from "./isRouterLinkedField";
import { getFieldWithOptions } from "./selectOptions";
/**
* Doesn't have deleted fields by default
@@ -57,7 +58,9 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
name: f.name,
description: f.description,
}));
const finalFields = fields;
const finalFields = fields.map((field) => getFieldWithOptions(field));
let teamMembers: SerializableFormTeamMembers[] = [];
if (form.teamId) {
teamMembers = await prisma.user.findMany({
@@ -4,7 +4,7 @@ import type { App_RoutingForms_Form } from "@prisma/client";
import { Utils as QbUtils } from "react-awesome-query-builder";
import type { z } from "zod";
import type { Response, Route, SerializableForm } from "../types/types";
import type { FormResponse, Route, SerializableForm } from "../types/types";
import type { zodNonRouterRoute } from "../zod";
import { getQueryBuilderConfig } from "./getQueryBuilderConfig";
import { isFallbackRoute } from "./isFallbackRoute";
@@ -16,7 +16,7 @@ export function processRoute({
response,
}: {
form: SerializableForm<App_RoutingForms_Form>;
response: Record<string, Pick<Response[string], "value">>;
response: Record<string, Pick<FormResponse[string], "value">>;
}) {
const queryBuilderConfig = getQueryBuilderConfig(form);
@@ -52,7 +52,7 @@ export function processRoute({
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
let result = false;
const responseValues: Record<string, Response[string]["value"]> = {};
const responseValues: Record<string, FormResponse[string]["value"]> = {};
for (const [uuid, { value }] of Object.entries(response)) {
responseValues[uuid] = value;
}
@@ -0,0 +1,51 @@
/**
* @fileoverview
*
* This file holds the utilities to build the options to render in the select field and it could be loaded on client side as well.
*/
import type { z } from "zod";
import type { zodFieldView } from "../zod";
type Field = z.infer<typeof zodFieldView>;
const buildOptionsFromLegacySelectText = ({ legacySelectText }: { legacySelectText: string }) => {
return legacySelectText
.trim()
.split("\n")
.map((fieldValue) => ({
label: fieldValue,
id: null,
}));
};
export const getFieldWithOptions = <T extends Field>(field: T) => {
const legacySelectText = field.selectText;
if (field.options) {
return {
...field,
options: field.options,
};
} else if (legacySelectText) {
const options = buildOptionsFromLegacySelectText({ legacySelectText });
return {
...field,
options,
};
}
return {
...field,
};
};
export function getUIOptionsForSelect(field: Field) {
return getFieldWithOptions(field).options?.map((option) => {
// We prefer option.id as that doesn't change when we change the option text/label.
// Fallback to option.label for fields saved in DB in old format which didn't have `options`
const value = option.id ?? option.label;
return {
value,
title: option.label,
};
});
}
@@ -1,11 +1,11 @@
import slugify from "@calcom/lib/slugify";
import type { Response, Route, Field } from "../types/types";
import type { FormResponse, Route, Field } from "../types/types";
import getFieldIdentifier from "./getFieldIdentifier";
export const substituteVariables = (
routeValue: Route["action"]["value"],
response: Response,
response: FormResponse,
fields: Field[]
) => {
const regex = /\{([^\}]+)\}/g;
@@ -15,7 +15,11 @@ export const substituteVariables = (
variables.forEach((variable) => {
for (const key in response) {
const identifier = getFieldIdentifier(fields.find((field) => field.id === key));
const field = fields.find((field) => field.id === key)
if (!field) {
continue;
}
const identifier = getFieldIdentifier(field);
if (identifier.toLowerCase() === variable.toLowerCase()) {
eventTypeUrl = eventTypeUrl.replace(`{${variable}}`, slugify(response[key].value.toString() || ""));
}
@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import { getFieldResponseForJsonLogic } from "./transformResponse";
describe("getFieldResponseForJsonLogic", () => {
it("should return an empty string if value is undefined", () => {
const field = { type: "text", options: undefined };
const value = undefined;
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toBe("");
});
it("should transform value for a number type field to number", () => {
const field = { type: "number", options: undefined };
const value = "123";
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toBe(123);
});
describe("multiselect", () => {
describe("non-legacy options", () => {
it("should return option ids for the field if the value is an array of option ids", () => {
const field = {
type: "multiselect",
options: [
{ id: "1", label: "Option 1" },
{ id: "2", label: "Option 2" },
],
};
const value = ["1", "2"];
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toEqual(["1", "2"]);
});
it("should return ids if matching labels are provided in value", () => {
const field = {
type: "multiselect",
options: [
{ id: "1", label: "Option 1" },
{ id: "2", label: "Option 2" },
],
};
const value = ["Option 1", "Option 2"];
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toEqual(["1", "2"]);
});
it("should return the value as it is if it doesn't match any of the labels or option ids", () => {
const field = {
type: "multiselect",
options: [
{ id: "1", label: "Option 1" },
{ id: "2", label: "Option 2" },
],
};
const value = ["Option 10", "Option 11"];
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toEqual(["Option 10", "Option 11"]);
});
});
describe("legacy options", () => {
it("should return what is given for a field that has legacy options but the values don't match labels", () => {
const field = {
type: "multiselect",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
};
const value = ["1", "2"];
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toEqual(["1", "2"]);
});
it("should return matching labels for a field that has legacy options", () => {
const field = {
type: "multiselect",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
};
const value = ["Option 1", "Option 2"];
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toEqual(["Option 1", "Option 2"]);
});
it("should handle multiselect field with legacy options", () => {
const field = {
type: "multiselect",
options: [
{ label: "Option 1", id: null },
{ label: "Option 2", id: null },
],
};
const value = ["Option 1", "Option 2"];
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toEqual(["Option 1", "Option 2"]);
});
});
});
describe("select", () => {
it("should handle select field with options", () => {
const field = {
type: "select",
options: [
{ id: "1", label: "Option 1" },
{ id: "2", label: "Option 2" },
],
};
const value = "1";
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toBe("1");
});
it("should handle select field with legacy options", () => {
const field = {
type: "select",
options: [
{ label: "Option 1", id: null },
{ label: "Option 2", id: null },
],
};
const value = "Option 1";
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toBe("Option 1");
});
it("should handle select field with number as string", () => {
const field = {
type: "select",
options: [
{ id: "1", label: "Option 1" },
{ id: "2", label: "Option 2" },
],
};
const value = 1;
const result = getFieldResponseForJsonLogic({ field, value });
expect(result).toBe("1");
});
});
});
@@ -1,11 +1,59 @@
import type { Field, Response } from "../types/types";
import type { Field, FormResponse } from "../types/types";
export default function transformResponse({
/**
* It takes care of correctly transforming the input to label or id depending on various cases
* - It allows us to prefill with ID or Label
* - It allows backward compatibility with legacy routes(with labels for options)
*/
function transformSelectValue({
field,
idOrLabel,
}: {
field: Pick<Field, "options" | "type">;
idOrLabel: string;
}) {
idOrLabel = idOrLabel.trim();
const options = field.options;
if (!options) {
return idOrLabel;
}
const areOptionsInLegacyFormat = !!options.find((option) => !option.id);
// Because for legacy saved options, routes must have labels in them instead of ids
const shouldUseLabelAsValue = areOptionsInLegacyFormat;
const foundOptionById = options.find((option) => option.id === idOrLabel);
if (foundOptionById) {
if (shouldUseLabelAsValue) {
return foundOptionById.label;
} else {
// If shouldUseLabelAsValue is false, then we must use id as value
// Because shouldUseLabelAsValue is false, id must be set already
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return foundOptionById.id!;
}
} else {
// No option was found that matches ID
// So check if the label is provided
const foundOptionByLabel = options.find((option) => {
return option.label === idOrLabel;
});
if (foundOptionByLabel) {
if (!shouldUseLabelAsValue) {
// If shouldUseLabelAsValue is false, then we must use id as value
// Because shouldUseLabelAsValue is false, id must be set already
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return foundOptionByLabel.id!;
}
}
}
return idOrLabel;
}
export function getFieldResponseForJsonLogic({
field,
value,
}: {
field: Field;
value: Response[string]["value"] | undefined;
field: Pick<Field, "options" | "type">;
value: FormResponse[string]["value"] | undefined;
}) {
if (!value) {
return "";
@@ -18,13 +66,24 @@ export default function transformResponse({
return value;
}
if (field.type === "multiselect") {
if (value instanceof Array) {
return value;
}
return value
.toString()
.split(",")
.map((v) => v.trim());
// Could be option id(i.e. a UUIDv4) or option label for ease of prefilling
let valueOrLabelArray = value instanceof Array ? value : value.toString().split(",");
valueOrLabelArray = valueOrLabelArray.map((idOrLabel) => {
return transformSelectValue({ field, idOrLabel });
});
return valueOrLabelArray;
}
if (field.type === "select") {
const valueAsStringOrStringArray = typeof value === "number" ? String(value) : value;
const valueAsString =
valueAsStringOrStringArray instanceof Array
? valueAsStringOrStringArray[0]
: valueAsStringOrStringArray;
return transformSelectValue({ field, idOrLabel: valueAsString });
}
return value;
}
@@ -1,7 +1,7 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useState } from "react";
import type { ClipboardEvent } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useFieldArray, useWatch } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
@@ -29,8 +29,26 @@ import SingleForm, {
} from "../../components/SingleForm";
export { getServerSideProps };
type SelectOption = { label: string; id: string | null };
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
type SelectOption = { placeholder: string; value: string; id: string };
const appendArray = <T,>({
target,
arrayToAppend,
appendAt,
}: {
arrayToAppend: T[];
target: T[];
appendAt: number;
}) => {
// Avoid mutating the original array
const copyOfTarget = [...target];
const numItemsToRemove = arrayToAppend.length;
copyOfTarget.splice(appendAt, numItemsToRemove, ...arrayToAppend);
return copyOfTarget;
};
const PASTE_OPTIONS_SEPARATOR_REGEX = /\n+/;
export const FieldTypes = [
{
@@ -90,65 +108,51 @@ function Field({
}) {
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
const watchedOptions =
useWatch({
control: hookForm.control,
name: `${hookFieldNamespace}.options`,
defaultValue: [
{ label: "", id: uuidv4() },
{ label: "", id: uuidv4() },
{ label: "", id: uuidv4() },
{ label: "", id: uuidv4() },
],
}) || [];
const [options, setOptions] = useState<SelectOption[]>([
{ placeholder: "< 10", value: "", id: uuidv4() },
{ placeholder: "10-100", value: "", id: uuidv4() },
{ placeholder: "100-500", value: "", id: uuidv4() },
{ placeholder: "> 500", value: "", id: uuidv4() },
]);
const handleRemoveOptions = (index: number) => {
const updatedOptions = options.filter((_, i) => i !== index);
setOptions(updatedOptions);
updateSelectText(updatedOptions);
const setOptions = (updatedOptions: SelectOption[]) => {
hookForm.setValue(`${hookFieldNamespace}.options`, updatedOptions);
};
const handleAddOptions = () => {
setOptions((prevState) => [
...prevState,
const handleRemoveOptions = (index: number) => {
const updatedOptions = watchedOptions.filter((_, i) => i !== index);
// We can't let the user remove the last option
if (updatedOptions.length === 0) {
return;
}
setOptions(updatedOptions);
};
const addOption = () => {
setOptions([
...watchedOptions,
{
placeholder: "New Option",
value: "",
label: "",
id: uuidv4(),
},
]);
};
useEffect(() => {
const originalValues = hookForm.getValues(`${hookFieldNamespace}.selectText`);
if (originalValues) {
const values: SelectOption[] = originalValues.split("\n").map((fieldValue) => ({
value: fieldValue,
placeholder: "",
id: uuidv4(),
}));
setOptions(values);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, optionIndex: number) => {
const updatedOptions = options.map((opt, index) => ({
const updateLabelAtIndex = ({ label, optionIndex }: { label: string; optionIndex: number }) => {
const updatedOptions = watchedOptions.map((opt, index) => ({
...opt,
...(index === optionIndex ? { value: e.target.value } : {}),
...(index === optionIndex ? { label } : {}),
}));
setOptions(updatedOptions);
updateSelectText(updatedOptions);
};
const updateSelectText = (updatedOptions: SelectOption[]) => {
hookForm.setValue(
`${hookFieldNamespace}.selectText`,
updatedOptions
.filter((opt) => opt.value)
.map((opt) => opt.value)
.join("\n")
);
};
const label = useWatch({
@@ -162,18 +166,49 @@ function Field({
});
function move(index: number, increment: 1 | -1) {
const newList = [...options];
const newList = [...watchedOptions];
const type = options[index];
const tmp = options[index + increment];
const type = watchedOptions[index];
const tmp = watchedOptions[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
setOptions(newList);
updateSelectText(newList);
}
const handlePasteInOptionAtIndex = ({
event,
optionIndex,
}: {
event: ClipboardEvent;
optionIndex: number;
}) => {
const paste = event.clipboardData.getData("text");
// The value being pasted could be a list of options
const optionsBeingPasted = paste
.split(PASTE_OPTIONS_SEPARATOR_REGEX)
.map((optionLabel) => optionLabel.trim())
.filter((optionLabel) => optionLabel)
.map((optionLabel) => ({ label: optionLabel.trim(), id: uuidv4() }));
if (optionsBeingPasted.length === 1) {
// If there is only one option, we would just let that option be pasted
return;
}
// Don't allow pasting that value, as we would update the options through state update
event.preventDefault();
const updatedOptions = appendArray({
target: watchedOptions,
arrayToAppend: optionsBeingPasted,
appendAt: optionIndex,
});
setOptions(updatedOptions);
};
const optionsPlaceholders = ["< 10", "10 - 100", "100 - 500", "> 500"];
return (
<div
data-testid="field"
@@ -261,10 +296,15 @@ function Field({
{t("options")}
</Skeleton>
<ul ref={animationRef}>
{options.map((field, index) => (
<li key={`select-option-${field.id}`} className="group mt-2 flex items-center gap-2">
{watchedOptions.map((option, index) => (
<li
key={`select-option-${option.id}`}
className="group mt-2 flex items-center gap-2"
onPaste={(event: ClipboardEvent) =>
handlePasteInOptionAtIndex({ event, optionIndex: index })
}>
<div className="flex flex-col gap-2">
{options.length && index !== 0 ? (
{watchedOptions.length && index !== 0 ? (
<button
type="button"
onClick={() => move(index, -1)}
@@ -272,7 +312,7 @@ function Field({
<Icon name="arrow-up" />
</button>
) : null}
{options.length && index !== options.length - 1 ? (
{watchedOptions.length && index !== watchedOptions.length - 1 ? (
<button
type="button"
onClick={() => move(index, 1)}
@@ -287,18 +327,22 @@ function Field({
containerClassName="[&>*:first-child]:border [&>*:first-child]:border-default hover:[&>*:first-child]:border-gray-400"
className="border-0 focus:ring-0 focus:ring-offset-0"
labelSrOnly
placeholder={field.placeholder.toString()}
value={field.value}
placeholder={optionsPlaceholders[index] ?? "New Option"}
value={option.label}
type="text"
required
addOnClassname="bg-transparent border-0"
onChange={(e) => handleChange(e, index)}
onChange={(e) => updateLabelAtIndex({ label: e.target.value, optionIndex: index })}
dataTestid={`${hookFieldNamespace}.options.${index}`}
addOnSuffix={
<button
type="button"
onClick={() => handleRemoveOptions(index)}
aria-label={t("remove")}>
<Icon name="x" className="h-4 w-4" />
</button>
watchedOptions.length > 1 ? (
<button
type="button"
onClick={() => handleRemoveOptions(index)}
aria-label={t("remove")}>
<Icon name="x" className="h-4 w-4" />
</button>
) : null
}
/>
</div>
@@ -312,7 +356,7 @@ function Field({
type="button"
StartIcon="plus"
color="secondary"
onClick={handleAddOptions}>
onClick={addOption}>
Add an option
</Button>
</div>
@@ -12,8 +12,8 @@ import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import transformResponse from "../../lib/transformResponse";
import type { Response } from "../../types/types";
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { FormResponse } from "../../types/types";
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
@@ -96,7 +96,7 @@ export const getServerSideProps = async function getServerSideProps(
form: enrichFormWithMigrationData(formWithUserProfile),
});
const response: Response = {};
const response: FormResponse = {};
if (!serializableForm.fields) {
throw new Error("Form has no fields");
}
@@ -105,7 +105,7 @@ export const getServerSideProps = async function getServerSideProps(
response[field.id] = {
label: field.label,
value: transformResponse({ field, value: fieldResponse }),
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
};
});
@@ -23,9 +23,10 @@ import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { processRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import transformResponse from "../../lib/transformResponse";
import type { NonRouterRoute, Response } from "../../types/types";
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { NonRouterRoute, FormResponse } from "../../types/types";
import { getServerSideProps } from "./getServerSideProps";
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({
@@ -59,10 +60,13 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionWithFormResponseRef = useRef<{ action: NonRouterRoute["action"]; response: Response }>();
const decidedActionWithFormResponseRef = useRef<{
action: NonRouterRoute["action"];
response: FormResponse;
}>();
const router = useRouter();
const onSubmit = (response: Response) => {
const onSubmit = (response: FormResponse) => {
const decidedAction = processRoute({ form, response });
if (!decidedAction) {
@@ -97,7 +101,11 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
if (!fields) {
throw new Error("Routing Form fields must exist here");
}
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
const allURLSearchParams = getUrlSearchParamsToForward({
formResponse: decidedActionWithFormResponse.response,
fields,
searchParams: new URLSearchParams(window.location.search),
});
const decidedAction = decidedActionWithFormResponse.action;
sdkActionManager?.fire("routed", {
actionType: decidedAction.type,
@@ -191,55 +199,6 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
);
}
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(response).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
const valueAsStringOrStringArray =
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
valueAsStringOrStringArray;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm {...props} />;
}
@@ -250,7 +209,7 @@ export { getServerSideProps };
const usePrefilledResponse = (form: Props["form"]) => {
const searchParams = useCompatSearchParams();
const prefillResponse: Response = {};
const prefillResponse: FormResponse = {};
// Prefill the form from query params
form.fields?.forEach((field) => {
@@ -259,10 +218,10 @@ const usePrefilledResponse = (form: Props["form"]) => {
const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0];
prefillResponse[field.id] = {
value: transformResponse({ field, value }),
value: getFieldResponseForJsonLogic({ field, value }),
label: field.label,
};
});
const [response, setResponse] = useState<Response>(prefillResponse);
const [response, setResponse] = useState<FormResponse>(prefillResponse);
return [response, setResponse] as const;
};
@@ -0,0 +1,216 @@
import { v4 as uuidv4 } from "uuid";
import { describe, it, expect } from "vitest";
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
const result: Record<string, string | string[]> = {};
if (entries === null) {
return result;
}
// Consider setting atleast ES2015 as target
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [key, value] of entries) {
if (result.hasOwnProperty(key)) {
let currentValue = result[key];
if (!Array.isArray(currentValue)) {
currentValue = [currentValue];
}
currentValue.push(value);
result[key] = currentValue;
} else {
result[key] = value;
}
}
return result;
}
describe("getUrlSearchParamsToForward", () => {
it("should build query params from response correctly when identifier is present in fields", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const formResponse = {
[field1Id]: { value: "value1", label: "Field 1" },
[field2Id]: { value: ["option1", "option2"], label: "Field 2" },
};
const fields = [
{ id: field1Id, identifier: "f1", type: "text", label: "Field 1" },
{
id: field2Id,
identifier: "f2",
label: "Field 2",
type: "multiselect",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
const expectedParams = {
f1: "value1",
f2: ["Option 1", "Option 2"],
query1: "value1",
query2: "value2",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
it("should build query params from response correctly when identifier is not present in fields. Should fallback to label", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const formResponse = {
[field1Id]: { value: "value1" },
[field2Id]: { value: "value2" },
};
const fields = [
{ id: field1Id, label: "Field 1", type: "text" },
{ id: field2Id, label: "Field 2", type: "text" },
];
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
const expectedParams = {
"Field 1": "value1",
"Field 2": "value2",
query1: "value1",
query2: "value2",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
it("should handle select fields correctly when options have id set", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const formResponse = {
[field1Id]: { value: "Option 1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
};
const fields = [
{
id: field1Id,
label: "Field 1",
type: "select",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
{
id: field2Id,
label: "Field 2",
type: "multiselect",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
{ id: "option3", label: "Option 3" },
],
},
];
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
const expectedParams = {
"Field 1": "Option 1",
"Field 2": ["Option 1", "Option 2"],
query1: "value1",
query2: "value2",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
it("should handle select fields correctly when options have no id set(Legacy options)", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const formResponse = {
[field1Id]: { value: "Option 1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
};
const fields = [
{
id: field1Id,
label: "Field 1",
type: "select",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
{
id: field2Id,
label: "Field 2",
type: "multiselect",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
{ id: "option3", label: "Option 3" },
],
},
];
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
const expectedParams = {
"Field 1": "Option 1",
"Field 2": ["Option 1", "Option 2"],
query1: "value1",
query2: "value2",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
it("should handle number values correctly", () => {
const field1Id = uuidv4();
const response = {
[field1Id]: { value: 123 },
};
const fields = [{ id: field1Id, label: "Field 1", type: "number" }];
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
const expectedParams = {
"Field 1": "123",
query1: "value1",
query2: "value2",
};
const result = getUrlSearchParamsToForward({
formResponse: response,
fields,
searchParams,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
});
@@ -0,0 +1,87 @@
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import type { getServerSideProps } from "./getServerSideProps";
type Props = inferSSRProps<typeof getServerSideProps>;
export function getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
}: {
formResponse: Record<
string,
{
value: number | string | string[];
}
>;
fields: Pick<
NonNullable<Props["form"]["fields"]>[number],
"id" | "type" | "options" | "identifier" | "label"
>[];
searchParams: URLSearchParams;
}) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(formResponse).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
let valueAsStringOrStringArray =
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
if (foundField.type === "select" || foundField.type === "multiselect") {
const options = foundField.options || [];
let arr =
valueAsStringOrStringArray instanceof Array
? valueAsStringOrStringArray
: [valueAsStringOrStringArray];
arr = arr.map((idOrLabel) => {
const foundOptionById = options.find((option) => {
return option.id === idOrLabel;
});
if (foundOptionById) {
return foundOptionById.label;
}
return idOrLabel;
});
valueAsStringOrStringArray = foundField.type === "select" ? arr[0] : arr;
}
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
valueAsStringOrStringArray;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of searchParams.entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
@@ -17,6 +17,11 @@ function todo(title: string) {
test.skip(title, () => {});
}
const Identifiers = {
multi: "multi",
multiNewFormat: "multi-new-format",
};
test.describe("Routing Forms", () => {
test.describe("Zero State Routing Forms", () => {
test("should be able to add a new form and view it", async ({ page }) => {
@@ -254,11 +259,11 @@ test.describe("Routing Forms", () => {
responses.push(rowResponses);
}
expect(headers).toEqual(["Test field", "Multi Select"]);
expect(headers).toEqual(["Test field", "Multi Select(with Legacy `selectText`)", "Multi Select"]);
expect(responses).toEqual([
["event-routing", ""],
["external-redirect", ""],
["custom-page", ""],
["event-routing", "", ""],
["external-redirect", "", ""],
["custom-page", "", ""],
]);
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
@@ -281,19 +286,21 @@ test.describe("Routing Forms", () => {
});
const csvRows = csv.trim().split("\n");
const csvHeaderRow = csvRows[0];
expect(csvHeaderRow).toEqual("Test field,Multi Select,Submission Time");
expect(csvHeaderRow).toEqual(
"Test field,Multi Select(with Legacy `selectText`),Multi Select,Submission Time"
);
const firstResponseCells = csvRows[1].split(",");
const secondResponseCells = csvRows[2].split(",");
const thirdResponseCells = csvRows[3].split(",");
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,");
expect(firstResponseCells.slice(0, -1).join(",")).toEqual("event-routing,,");
expect(new Date(firstResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,");
expect(secondResponseCells.slice(0, -1).join(",")).toEqual("external-redirect,,");
expect(new Date(secondResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,");
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual("custom-page,,");
expect(new Date(thirdResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
});
@@ -318,8 +325,13 @@ test.describe("Routing Forms", () => {
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multi}=Option-2`);
await expect(page.locator("text=Multiselect(Legacy) chosen")).toBeVisible({ timeout: 10000 });
await page.goto(
`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multiNewFormat}=d1302635-9f12-17b1-9153-c3a854649182`
);
await expect(page.locator("text=Multiselect chosen")).toBeVisible({ timeout: 10000 });
});
test("Routing Link should validate fields", async ({ page, users }) => {
@@ -364,8 +376,34 @@ test.describe("Routing Forms", () => {
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("External Redirect");
expect(route).toBe("https://google.com");
await page.click('[data-testid="dialog-rejection"]');
// Multiselect(Legacy)
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
await page.click(`[data-testid="form-field-${Identifiers.multi}"]`); // Open dropdown
await page.click("text=Option-2"); // Select option
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Multiselect(Legacy) chosen");
await page.click('[data-testid="dialog-rejection"]');
// Multiselect
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
await page.click(`[data-testid="form-field-${Identifiers.multiNewFormat}"]`); // Open dropdown
await page.click("text=Option-2"); // Select option
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Multiselect chosen");
await page.click('[data-testid="dialog-rejection"]');
//fallback route
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
@@ -452,8 +490,11 @@ async function addAllTypesOfFieldsAndSaveForm(
identifier = "firstField";
}
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
if (fieldTypeLabel === "Multiple Selection" || fieldTypeLabel === "Single Selection") {
await page.fill(`[data-testid="fields.${nth}.options.0-input"]`, "123");
await page.fill(`[data-testid="fields.${nth}.options.1-input"]`, "456");
await page.fill(`[data-testid="fields.${nth}.options.2-input"]`, "789");
await page.fill(`[data-testid="fields.${nth}.options.3-input"]`, "10-11-12");
}
await page.fill(`[name="fields.${nth}.label"]`, label);
@@ -0,0 +1,158 @@
import { v4 as uuidv4 } from "uuid";
import { describe, it, expect } from "vitest";
import { buildResponsesForReporting } from "./report.handler";
describe("buildResponsesForReporting", () => {
it("for fields with options, it should return the labels of the options", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["option1", "option2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("for fields with no options but having array value, it should return the value as is", () => {
const field2Id = uuidv4();
const responsesFromDb = [
{
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{
id: field2Id,
label: "Field 2",
},
];
const expectedResponses = [["Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 2"]);
});
it("should correctly handle numeric responses converting them to strings", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: 1 },
[field2Id]: { value: [1, 2] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
];
const expectedResponses = [["1", "1, 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("should handle empty responses", () => {
const field1Id = uuidv4();
const responsesFromDb = [{}];
const fields = [{ id: field1Id, label: "Field 1" }];
const expectedResponses = [[""]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1"]);
});
it("should show correct header for deleted fields", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
deleted: true,
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(expectedHeaders);
});
});
@@ -1,10 +1,14 @@
import type { z } from "zod";
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 { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { Response } from "../types/types";
import type { FormResponse } from "../types/types";
import type { zodNonRouterField, zodFieldView } from "../zod";
import type { TReportInputSchema } from "./report.schema";
interface ReportHandlerOptions {
@@ -13,6 +17,45 @@ interface ReportHandlerOptions {
};
input: TReportInputSchema;
}
type Field = z.infer<typeof zodNonRouterField>;
function ensureStringOrStringArray(value: string | number | (string | number)[]): string | string[] {
if (typeof value === "string") {
return value;
} else if (value instanceof Array) {
return value.map((v) => v.toString());
}
return [value.toString()];
}
function getLabelsFromOptionIds({
options,
optionIds,
}: {
options: NonNullable<Field["options"]>;
optionIds: string | string[];
}) {
if (optionIds instanceof Array) {
const labels = optionIds.map((optionId) => {
const foundOption = options.find((option) => option.id === optionId);
// It would mean that the optionId is actually a label which is why it isn't matching any option id.
// Fallback to optionId(i.e. label) which was the case with legacy options
if (!foundOption) {
return optionId;
}
return foundOption.label;
});
return labels;
} else {
const foundOption = options.find((option) => option.id === optionIds);
if (!foundOption) {
return [optionIds];
}
return [foundOption.label];
}
}
export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -49,27 +92,13 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
take,
skip,
});
const fields = serializedForm?.fields || [];
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: (string | number)[][] = [];
rows.forEach((r) => {
const rowResponses: (string | number)[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r.response) {
return;
}
const response = r.response as Response;
const value = response[field.id]?.value || "";
let transformedValue;
if (value instanceof Array) {
transformedValue = value.join(", ");
} else {
transformedValue = value;
}
rowResponses.push(transformedValue);
});
const { responses, headers } = buildResponsesForReporting({
responsesFromDb: rows.map((r) => r.response),
fields,
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
headers,
@@ -79,3 +108,36 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
};
export default reportHandler;
export function buildResponsesForReporting({
responsesFromDb,
fields,
}: {
responsesFromDb: App_RoutingForms_FormResponse["response"][];
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
}) {
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: string[][] = [];
responsesFromDb.forEach((r) => {
const rowResponses: string[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r) {
return;
}
const response = r as FormResponse;
const value = response[field.id]?.value || "";
if (field.options) {
const optionIds = ensureStringOrStringArray(value);
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
rowResponses.push(labels.join(", "));
} else {
const arrayOfValues = value instanceof Array ? value : [value];
const transformedValue = arrayOfValues.join(", ");
rowResponses.push(transformedValue);
}
});
});
return { responses, headers };
}
@@ -6,7 +6,7 @@ import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { Response } from "../types/types";
import type { FormResponse } from "../types/types";
import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission } from "./utils";
@@ -117,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as Response
dbFormResponse.response as FormResponse
);
return dbFormResponse;
} catch (e) {
+82 -15
View File
@@ -8,30 +8,97 @@ import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import type { OrderedResponses } from "../types/types";
import type { Response, SerializableForm } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
type Field = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
function isOptionsField(field: Pick<Field, "type" | "options">) {
return (field.type === "select" || field.type === "multiselect") && field.options;
}
function getFieldResponse({
field,
fieldResponseValue,
}: {
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
field: Pick<Field, "type" | "options">;
}) {
if (!isOptionsField(field)) {
return {
value: fieldResponseValue,
response: fieldResponseValue,
};
}
if (!field.options) {
return {
value: fieldResponseValue,
response: fieldResponseValue,
};
}
const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue];
const chosenOptions = valueArray.map((idOrLabel) => {
const foundOptionById = field.options?.find((option) => {
return option.id === idOrLabel;
});
if (foundOptionById) {
return {
label: foundOptionById.label,
id: foundOptionById.id,
};
} else {
return {
label: idOrLabel.toString(),
id: null,
};
}
});
return {
// value is a legacy prop that is just sending the labels which can change
value: chosenOptions.map((option) => option.label),
// response is new prop that is sending the label along with id(which doesn't change)
response: chosenOptions,
};
}
type SelectFieldWebhookResponse = string | number | string[] | { label: string; id: string | null };
type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record<
string,
{
/**
* Deprecates `value` prop as it now has both the id(that doesn't change) and the label(that can change but is human friendly)
*/
response: number | string | string[] | SelectFieldWebhookResponse | SelectFieldWebhookResponse[];
/**
* @deprecated Use `response` instead
*/
value: FormResponse[keyof FormResponse]["value"];
}
>;
export async function onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
"fields"
>,
response: Response
response: FormResponse
) {
const fieldResponsesByName: Record<
string,
{
value: Response[keyof Response]["value"];
}
> = {};
const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
const field = form.fields.find((f) => f.id === fieldId);
if (!field) {
throw new Error(`Field with id ${fieldId} not found`);
}
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = {
value: fieldResponse.value,
};
(fieldResponse.label as keyof typeof fieldResponsesByIdentifier);
fieldResponsesByIdentifier[key] = getFieldResponse({
fieldResponseValue: fieldResponse.value,
field,
});
}
const { userId, teamId } = getWebhookTargetEntity(form);
@@ -57,14 +124,14 @@ export async function onFormSubmission(
formId: form.id,
formName: form.name,
teamId: form.teamId,
responses: fieldResponsesByName,
responses: fieldResponsesByIdentifier,
},
rootData: {
// Send responses unwrapped at root level for backwards compatibility
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
...Object.entries(fieldResponsesByIdentifier).reduce((acc, [key, value]) => {
acc[key] = value.value;
return acc;
}, {} as Record<string, Response[keyof Response]["value"]>),
}, {} as Record<string, FormResponse[keyof FormResponse]["value"]>),
},
}).catch((e) => {
console.error(`Error executing routing form webhook`, webhook, e);
+3 -3
View File
@@ -10,7 +10,7 @@ export type RoutingForm = SerializableForm<App_RoutingForms_Form>;
export type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export type Response = Record<
export type FormResponse = Record<
// Field ID
string,
{
@@ -20,7 +20,7 @@ export type Response = Record<
>;
export type Fields = z.infer<typeof zodFieldsView>;
export type Field = Fields[number];
export type Field = NonNullable<Fields>[number];
export type Routes = z.infer<typeof zodRoutesView>;
export type Route = Routes[0];
export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>;
@@ -55,4 +55,4 @@ export type SerializableRoute =
})
| GlobalRoute;
export type OrderedResponses = Response[string][];
export type OrderedResponses = FormResponse[string][];
+14
View File
@@ -6,9 +6,23 @@ export const zodNonRouterField = z.object({
identifier: z.string().optional(),
placeholder: z.string().optional(),
type: z.string(),
/**
* @deprecated in favour of `options`
*/
selectText: z.string().optional(),
required: z.boolean().optional(),
deleted: z.boolean().optional(),
options: z
.array(
z.object({
label: z.string(),
// To keep backwards compatibility with the options generated from legacy selectText, we allow saving null as id
// It helps in differentiating whether the routing logic should consider the option.label as value or option.id as value.
// This is important for legacy routes which has option.label saved in conditions and it must keep matching with the value of the option
id: z.string().or(z.null()),
})
)
.optional(),
});
export const zodRouterField = zodNonRouterField.extend({
@@ -9,6 +9,7 @@ import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const siteId = getAppData("SITE_ID");
const scriptURL = getAppData("SCRIPT_URL");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
@@ -19,15 +20,27 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
disabled={disabled}
name="Site ID"
value={siteId}
placeholder="Enter your Site ID"
onChange={(e) => {
setAppData("SITE_ID", e.target.value);
}}
/>
<fieldset className="space-y-2" disabled={disabled}>
<TextField
disabled={disabled}
name="Script URL"
value={scriptURL}
defaultValue="https://us.umami.is/script.js"
placeholder="Enter the script source URL"
onChange={(e) => {
setAppData("SCRIPT_URL", e.target.value);
}}
/>
<TextField
disabled={disabled}
name="Site ID"
value={siteId}
placeholder="Enter your Site ID"
onChange={(e) => {
setAppData("SITE_ID", e.target.value);
}}
/>
</fieldset>
</AppCard>
);
};
+1 -1
View File
@@ -14,7 +14,7 @@
"tag": {
"scripts": [
{
"src": "https://us.umami.is/script.js",
"src": "{SCRIPT_URL}",
"attrs": {
"data-website-id": "{SITE_ID}"
}
+1
View File
@@ -5,6 +5,7 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
SITE_ID: z.string().optional(),
SCRIPT_URL: z.string().optional(),
})
);
+51
View File
@@ -0,0 +1,51 @@
import type { EventStatus } from "ics";
import type { TFunction } from "next-i18next";
import type { CalendarEvent } from "@calcom/types/Calendar";
import generateIcsString from "./generateIcsString";
export enum GenerateIcsRole {
ATTENDEE = "attendee",
ORGANIZER = "organizer",
}
export default function generateIcsFile({
calEvent,
title,
subtitle,
role,
status,
t,
isRequestReschedule,
}: {
calEvent: CalendarEvent;
title: string;
subtitle: string;
role: GenerateIcsRole;
status: EventStatus;
t?: TFunction;
isRequestReschedule?: boolean;
}) {
// O365 deletes emails if the calendar event is selected. Currently no option to disable this on the web
if (
role !== GenerateIcsRole.ATTENDEE &&
calEvent.destinationCalendar &&
calEvent.destinationCalendar[0].integration === "office365_calendar"
)
return null;
return {
filename: "event.ics",
content: generateIcsString({
event: calEvent,
title,
subtitle,
role,
status,
t,
isRequestReschedule,
}),
method: "REQUEST",
};
}
+4 -2
View File
@@ -9,6 +9,8 @@ import { getWhen } from "@calcom/lib/CalEventParser";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { GenerateIcsRole } from "./generateIcsFile";
export enum BookingAction {
Create = "create",
Cancel = "cancel",
@@ -30,7 +32,7 @@ const generateIcsString = ({
title: string;
subtitle: string;
status: EventStatus;
role: "attendee" | "organizer";
role: GenerateIcsRole;
isRequestReschedule?: boolean;
t?: TFunction;
}) => {
@@ -47,7 +49,7 @@ const generateIcsString = ({
const getTextBody = (title: string, subtitle: string): string => {
let body: string;
if (isRequestReschedule && role === "attendee" && t) {
if (isRequestReschedule && role === GenerateIcsRole.ATTENDEE && t) {
body = `
${title}
${getWhen(event, t)}
@@ -1,21 +1,17 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("new_guests_added"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("new_guests_added"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ATTENDEE,
status: "CONFIRMED",
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
@@ -1,21 +1,17 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_request_cancelled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CANCELLED",
role: "attendee",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("event_request_cancelled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ATTENDEE,
status: "CANCELLED",
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
@@ -1,21 +1,17 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_location_changed"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("event_location_changed"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ATTENDEE,
status: "CONFIRMED",
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
@@ -1,21 +1,17 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_type_has_been_rescheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("event_type_has_been_rescheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ATTENDEE,
status: "CONFIRMED",
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
@@ -7,7 +7,7 @@ import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import BaseEmail from "./_base-email";
export default class AttendeeScheduledEmail extends BaseEmail {
@@ -33,19 +33,15 @@ export default class AttendeeScheduledEmail extends BaseEmail {
const clonedCalEvent = cloneDeep(this.calEvent);
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.calEvent.recurringEvent?.count
? this.t("your_event_has_been_scheduled_recurring")
: this.t("your_event_has_been_scheduled"),
role: "attendee",
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.calEvent.recurringEvent?.count
? this.t("your_event_has_been_scheduled_recurring")
: this.t("your_event_has_been_scheduled"),
role: GenerateIcsRole.ATTENDEE,
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CONFIRMED",
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
@@ -3,7 +3,7 @@ import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from "..";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail {
@@ -17,19 +17,15 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche
const toAddresses = [this.calEvent.attendees[0].email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("request_reschedule_booking"),
subtitle: this.t("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
}),
role: "attendee",
status: "CANCELLED",
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("request_reschedule_booking"),
subtitle: this.t("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
}),
method: "REQUEST",
},
role: GenerateIcsRole.ATTENDEE,
status: "CANCELLED",
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("requested_to_reschedule_subject_attendee", {
@@ -1,7 +1,7 @@
import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
@@ -9,17 +9,13 @@ export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("new_guests_added"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "organizer",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("new_guests_added"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ORGANIZER,
status: "CONFIRMED",
}),
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
@@ -1,7 +1,7 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
@@ -9,17 +9,13 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_request_cancelled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CANCELLED",
role: "organizer",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("event_request_cancelled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CANCELLED",
role: GenerateIcsRole.ORGANIZER,
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("event_cancelled_subject", {
@@ -1,7 +1,7 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmail {
@@ -9,17 +9,13 @@ export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmai
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_location_changed"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "organizer",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("event_location_changed"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ORGANIZER,
status: "CONFIRMED",
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
@@ -7,7 +7,7 @@ import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from "..";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerRequestedToRescheduleEmail extends OrganizerScheduledEmail {
@@ -20,21 +20,17 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu
const toAddresses = [this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("request_reschedule_title_organizer", {
attendee: this.calEvent.attendees[0].name,
}),
subtitle: this.t("request_reschedule_subtitle_organizer", {
attendee: this.calEvent.attendees[0].name,
}),
role: "organizer",
status: "CANCELLED",
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("request_reschedule_title_organizer", {
attendee: this.calEvent.attendees[0].name,
}),
method: "REQUEST",
},
subtitle: this.t("request_reschedule_subtitle_organizer", {
attendee: this.calEvent.attendees[0].name,
}),
role: GenerateIcsRole.ORGANIZER,
status: "CANCELLED",
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("rescheduled_event_type_subject", {
@@ -1,7 +1,7 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
@@ -9,17 +9,13 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_type_has_been_rescheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "organizer",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.t("event_type_has_been_rescheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ORGANIZER,
status: "CONFIRMED",
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
@@ -8,7 +8,8 @@ import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import generateIcsFile from "../lib/generateIcsFile";
import { GenerateIcsRole } from "../lib/generateIcsFile";
import BaseEmail from "./_base-email";
export default class OrganizerScheduledEmail extends BaseEmail {
@@ -31,19 +32,15 @@ export default class OrganizerScheduledEmail extends BaseEmail {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.calEvent.recurringEvent?.count
? this.t("new_event_scheduled_recurring")
: this.t("new_event_scheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "organizer",
status: "CONFIRMED",
}),
method: "REQUEST",
},
icalEvent: generateIcsFile({
calEvent: this.calEvent,
title: this.calEvent.recurringEvent?.count
? this.t("new_event_scheduled_recurring")
: this.t("new_event_scheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: GenerateIcsRole.ORGANIZER,
status: "CONFIRMED",
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
@@ -107,6 +107,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
bookingFields: true,
seatsShowAttendees: true,
metadata: true,
schedulingType: true,
hosts: {
select: {
user: true,
@@ -357,7 +358,13 @@ async function handler(req: CustomRequest) {
smsReminderNumber: bookingToDelete.smsReminderNumber,
evt: {
...evt,
...{ eventType: { slug: bookingToDelete.eventType?.slug } },
...{
eventType: {
slug: bookingToDelete.eventType?.slug,
schedulingType: bookingToDelete.eventType?.schedulingType,
hosts: bookingToDelete.eventType?.hosts,
},
},
},
hideBranding: !!bookingToDelete.eventType?.owner?.hideBranding,
});
@@ -20,6 +20,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
@@ -135,6 +136,15 @@ export async function handleConfirmation(args: {
eventType: {
bookingFields: Prisma.JsonValue | null;
slug: string;
schedulingType: SchedulingType | null;
hosts: {
user: {
email: string;
destinationCalendar?: {
primaryEmail: string | null;
} | null;
};
}[];
owner: {
hideBranding?: boolean | null;
} | null;
@@ -175,6 +185,21 @@ export async function handleConfirmation(args: {
select: {
slug: true,
bookingFields: true,
schedulingType: true,
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
owner: {
select: {
hideBranding: true,
@@ -220,11 +245,26 @@ export async function handleConfirmation(args: {
select: {
slug: true,
bookingFields: true,
schedulingType: true,
owner: {
select: {
hideBranding: true,
},
},
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
},
},
uid: true,
@@ -249,7 +289,11 @@ export async function handleConfirmation(args: {
const evtOfBooking = {
...evt,
metadata: { videoCallUrl: meetingUrl },
eventType: { slug: eventTypeSlug },
eventType: {
slug: eventTypeSlug,
schedulingType: updatedBookings[index].eventType?.schedulingType,
hosts: updatedBookings[index].eventType?.hosts,
},
};
evtOfBooking.startTime = updatedBookings[index].startTime.toISOString();
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
@@ -1741,7 +1741,11 @@ async function handler(
loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error }));
}
const evtWithMetadata = { ...evt, metadata, eventType: { slug: eventType.slug } };
const evtWithMetadata = {
...evt,
metadata,
eventType: { slug: eventType.slug, schedulingType: eventType.schedulingType, hosts: eventType.hosts },
};
if (!eventType.metadata?.disableStandardEmails?.all?.attendee) {
await scheduleMandatoryReminder(
@@ -2654,7 +2654,12 @@ describe("handleNewBooking", () => {
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
expectAwaitingPaymentEmails({ organizer, booker, emails });
expectAwaitingPaymentEmails({
organizer,
booker,
emails,
subject: "complete_your_booking_subject",
});
expectBookingPaymentIntiatedWebhookToHaveBeenFired({
booker,
organizer,
@@ -392,8 +392,7 @@ describe("handleNewBooking", () => {
});
expectWorkflowToBeTriggered({
// emailsToReceive: [organizer.email].concat(otherTeamMembers.map(member => member.email)),
emailsToReceive: [organizer.email],
emailsToReceive: [organizer.email].concat(otherTeamMembers.map((member) => member.email)),
emails,
});
},
@@ -104,7 +104,17 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
await scheduleWorkflowReminders({
workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
calendarEvent: {
...evt,
...{
metadata,
eventType: {
slug: eventType.slug,
schedulingType: eventType.schedulingType,
hosts: eventType.hosts,
},
},
},
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
emailAttendeeSendToOverride: bookerEmail,
@@ -9,7 +9,7 @@ import logger from "@calcom/lib/logger";
import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { SchedulingType, WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
@@ -119,6 +119,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
switch (reminder.workflowStep.action) {
case WorkflowActions.EMAIL_HOST:
sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email;
const hosts = reminder?.booking?.eventType?.hosts
?.filter((host) =>
reminder.booking?.attendees.some((attendee) => attendee.email === host.user.email)
)
.map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email);
const schedulingType = reminder.booking.eventType?.schedulingType;
if (
hosts &&
(schedulingType === SchedulingType.COLLECTIVE || schedulingType === SchedulingType.ROUND_ROBIN)
) {
sendTo = sendTo ? [sendTo, ...hosts] : hosts;
}
break;
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = reminder.booking.attendees[0].email;
@@ -27,7 +27,14 @@ type PartialBooking =
| "attendees"
| "userPrimaryEmail"
| "smsReminderNumber"
> & { eventType: (Partial<EventType> & { team: { parentId?: number } }) | null } & {
> & {
eventType:
| (Partial<EventType> & {
team: { parentId?: number };
hosts: { user: { email: string; destinationCalendar?: { primaryEmail: string } } }[] | undefined;
})
| null;
} & {
user: Partial<User> | null;
})
| null;
@@ -159,6 +166,20 @@ export const select: Prisma.WorkflowReminderSelect = {
bookingFields: true,
title: true,
slug: true,
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
recurringEvent: true,
team: {
select: {
@@ -6,7 +6,7 @@ import {
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleEmailReminder } from "./emailReminderManager";
@@ -16,7 +16,11 @@ import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type ExtendedCalendarEvent = CalendarEvent & {
metadata?: { videoCallUrl: string | undefined };
eventType: { slug?: string };
eventType: {
slug?: string;
schedulingType?: SchedulingType | null;
hosts?: { user: { email: string; destinationCalendar?: { primaryEmail: string | null } | null } }[];
};
};
type ProcessWorkflowStepParams = {
@@ -85,6 +89,13 @@ const processWorkflowStep = async (
break;
case WorkflowActions.EMAIL_HOST:
sendTo = [evt.organizer?.email || ""];
const schedulingType = evt.eventType.schedulingType;
const isTeamEvent =
schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE;
if (isTeamEvent && evt.team?.members) {
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
}
break;
case WorkflowActions.EMAIL_ATTENDEE:
const attendees = !!emailAttendeeSendToOverride
@@ -121,10 +121,9 @@ export default function CreateEventTypeDialog({
await router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`);
if (isInfiniteScrollEnabled) {
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({
await utils.viewer.eventTypes.getEventTypesFromGroup.fetchInfinite({
group: { teamId: eventType.teamId, parentId: eventType.parentId },
limit: 10,
group: { teamId: eventType?.teamId, parentId: eventType?.parentId },
});
} else {
await utils.viewer.eventTypes.getByViewer.invalidate();
+3 -2
View File
@@ -214,7 +214,8 @@ const useBanners = () => {
const Layout = (props: LayoutProps) => {
const banners = useBanners();
const pathname = usePathname();
const isFullPageWithoutSidebar = pathname.startsWith("/apps/routing-forms/reporting/");
const { data: user } = trpc.viewer.me.useQuery();
const { boot } = useIntercom();
const pageTitle = typeof props.heading === "string" && !props.title ? props.heading : props.title;
@@ -253,7 +254,7 @@ const Layout = (props: LayoutProps) => {
<TimezoneChangeDialog />
<div className="flex min-h-screen flex-col">
{banners && !props.isPlatformUser && (
{banners && !props.isPlatformUser && !isFullPageWithoutSidebar && (
<div className="sticky top-0 z-10 w-full divide-y divide-black">
{Object.keys(banners).map((key) => {
if (key === "teamUpgradeBanner") {
@@ -1,21 +1,19 @@
import { z } from "zod";
import type {
BookingLimitsKeyOutputType_2024_06_14,
TransformBookingLimitsSchema_2024_06_14,
} from "@calcom/platform-types";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookingWindowPeriodOutputTypeEnum_2024_06_14,
BookingLimitsEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
import {
type CreateEventTypeInput_2024_06_14,
type Integration_2024_06_14,
type BusinessDaysWindow_2024_06_14,
type RangeWindow_2024_06_14,
type TransformFutureBookingsLimitSchema_2024_06_14,
type BookingLimitsKeyOutputType_2024_06_14,
type TransformBookingLimitsSchema_2024_06_14,
} from "@calcom/platform-types";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookingWindowPeriodOutputTypeEnum_2024_06_14,
} from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/interval-limits.enum";
const integrationsMapping: Record<Integration_2024_06_14, string> = {
"cal-video": "integrations:daily",
@@ -1,3 +1,8 @@
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookingWindowPeriodOutputTypeEnum_2024_06_14,
BookingLimitsEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
import type {
AddressLocation_2024_06_14,
IntegrationLocation_2024_06_14,
@@ -13,11 +18,6 @@ import type {
BusinessDaysWindow_2024_06_14,
BookingField_2024_06_14,
} from "@calcom/platform-types";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookingWindowPeriodOutputTypeEnum_2024_06_14,
} from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/interval-limits.enum";
import type { transformApiEventTypeBookingFields, transformApiEventTypeLocations } from "./api-request";
+1 -1
View File
@@ -78,7 +78,7 @@ const processResult = (
case "username_exists":
return {
statusCode: 418,
message: "A user exists with that username",
message: "A user exists with that username or email",
};
case "is_premium":
return { statusCode: 402, message: "This is a premium username." };
@@ -1,5 +1,6 @@
"use client";
import type { SetStateAction, Dispatch } from "react";
import { useMemo, useState, useEffect } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
@@ -14,7 +15,6 @@ import { availabilityAsString } from "@calcom/lib/availability";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { sortAvailabilityStrings } from "@calcom/lib/weekstart";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { TimeRange, WorkingHours } from "@calcom/types/schedule";
import {
@@ -32,7 +32,6 @@ import {
TimezoneSelect as WebTimezoneSelect,
Tooltip,
VerticalDivider,
showToast,
} from "@calcom/ui";
import { Icon } from "@calcom/ui";
@@ -97,6 +96,12 @@ type AvailabilitySettingsProps = {
customClassNames?: CustomClassNames;
disableEditableHeading?: boolean;
enableOverrides?: boolean;
bulkUpdateModalProps?: {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
save: ({ eventTypeIds }: { eventTypeIds: number[] }) => void;
isSaving: boolean;
};
};
const DeleteDialogButton = ({
@@ -246,21 +251,11 @@ export function AvailabilitySettings({
customClassNames,
disableEditableHeading = false,
enableOverrides = false,
bulkUpdateModalProps,
}: AvailabilitySettingsProps) {
const [openSidebar, setOpenSidebar] = useState(false);
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const { t, i18n } = useLocale();
const utils = trpc.useUtils();
const bulkUpdateDefaultAvailabilityMutation =
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation({
onSuccess: () => {
utils.viewer.availability.list.invalidate();
setBulkUpdateModal(false);
showToast(t("success"), "success");
},
});
const form = useForm<AvailabilityFormValues>({
defaultValues: {
...schedule,
@@ -353,7 +348,7 @@ export function AvailabilitySettings({
checked={value}
onCheckedChange={(checked) => {
onChange(checked);
setBulkUpdateModal(checked);
bulkUpdateModalProps?.setIsOpen(checked);
}}
/>
)}
@@ -362,12 +357,12 @@ export function AvailabilitySettings({
) : null}
</div>
{bulkUpdateModal && (
{bulkUpdateModalProps && bulkUpdateModalProps?.isOpen && (
<BulkEditDefaultForEventsModal
isPending={bulkUpdateDefaultAvailabilityMutation.isPending}
open={bulkUpdateModal}
setOpen={setBulkUpdateModal}
bulkUpdateFunction={bulkUpdateDefaultAvailabilityMutation.mutate}
isPending={bulkUpdateModalProps?.isSaving}
open={bulkUpdateModalProps?.isOpen}
setOpen={bulkUpdateModalProps.setIsOpen}
bulkUpdateFunction={bulkUpdateModalProps?.save}
/>
)}
@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
@@ -29,6 +30,16 @@ export const AvailabilitySettingsWebWrapper = () => {
const { data: travelSchedules, isPending: isPendingTravelSchedules } =
trpc.viewer.getTravelSchedules.useQuery();
const [isBulkUpdateModalOpen, setIsBulkUpdateModalOpen] = useState(false);
const bulkUpdateDefaultAvailabilityMutation =
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation({
onSuccess: () => {
utils.viewer.availability.list.invalidate();
setIsBulkUpdateModalOpen(false);
showToast(t("success"), "success");
},
});
const isDefaultSchedule = me.data?.defaultScheduleId === scheduleId;
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
@@ -101,6 +112,12 @@ export const AvailabilitySettingsWebWrapper = () => {
...values,
});
}}
bulkUpdateModalProps={{
isOpen: isBulkUpdateModalOpen,
setIsOpen: setIsBulkUpdateModalOpen,
save: bulkUpdateDefaultAvailabilityMutation.mutate,
isSaving: bulkUpdateDefaultAvailabilityMutation.isPending,
}}
/>
);
};
@@ -76,7 +76,7 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
<Dialog open={isDialogOpen}>
<DialogTrigger>
<Button
StartIcon="calendar-days"
StartIcon="calendar"
color="primary"
disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
+2 -1
View File
@@ -31,7 +31,8 @@
"typescript": "^4.9.4",
"vite": "^5.0.10",
"vite-plugin-dts": "^3.7.3",
"vite-plugin-inspect": "^0.8.4"
"vite-plugin-inspect": "^0.8.4",
"vite-plugin-node-polyfills": "^0.22.0"
},
"files": [
"dist",
+53 -46
View File
@@ -1,59 +1,66 @@
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { resolve } from "path";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import dts from "vite-plugin-dts";
import Inspect from "vite-plugin-inspect";
export default defineConfig({
optimizeDeps: {
include: [
"@calcom/lib",
"@calcom/features",
"@calcom/prisma",
"@calcom/dayjs",
"@calcom/platform-constants",
"@calcom/platform-types",
"@calcom/platform-utils",
],
},
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
build: {
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
const webAppUrl = env.NEXT_PUBLIC_WEBAPP_URL ?? "https://app.cal.com";
return {
optimizeDeps: {
include: [
"@calcom/lib",
"@calcom/features",
"@calcom/prisma",
"@calcom/dayjs",
"@calcom/platform-constants",
"@calcom/platform-types",
"@calcom/platform-utils",
],
},
lib: {
entry: [resolve(__dirname, "index.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
define: {
"process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
},
rollupOptions: {
external: ["react", "fs", "path", "os", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
build: {
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
},
lib: {
entry: [resolve(__dirname, "index.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
},
rollupOptions: {
external: ["react", "fs", "path", "os", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
},
resolve: {
alias: {
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
"@": path.resolve(__dirname, "./src"),
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"),
"@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/web/public/static/locales/en/common.json": path.resolve(
__dirname,
"../../../apps/web/public/static/locales/en/common.json"
),
resolve: {
alias: {
fs: resolve("../../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../../node_modules/rollup-plugin-node-builtins"),
"@": path.resolve(__dirname, "./src"),
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/platform-types": path.resolve(__dirname, "../types/index.ts"),
"@calcom/platform-utils": path.resolve(__dirname, "../constants/index.ts"),
"@calcom/web/public/static/locales/en/common.json": path.resolve(
__dirname,
"../../../apps/web/public/static/locales/en/common.json"
),
},
},
},
};
});
+5
View File
@@ -0,0 +1,5 @@
Contains enums shared by platform api and packages
from api-v2 please import from @calcom/platform-enums
from any other package please import from @calcom/platform-enums/monorepo

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