Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8dde843d | ||
|
|
c7ee3984ef | ||
|
|
18049bc1d6 | ||
|
|
447a0289d6 | ||
|
|
2802310268 | ||
|
|
2ec9e903ce | ||
|
|
db7547fd6d | ||
|
|
3c91703415 | ||
|
|
ca5d931165 | ||
|
|
3b2e3351c4 | ||
|
|
08a2b0bfb5 | ||
|
|
1f8f134854 | ||
|
|
5427d28c9e | ||
|
|
0d5560e0d1 | ||
|
|
ea15474edf | ||
|
|
6acc84661d | ||
|
|
5b048dd868 | ||
|
|
b02470e780 | ||
|
|
c536445eec | ||
|
|
09f33a840f | ||
|
|
16cb9a3126 | ||
|
|
844380e265 | ||
|
|
034fbd63f1 | ||
|
|
f536100224 | ||
|
|
906ba99d3c | ||
|
|
620445d85f | ||
|
|
c9bd18e866 |
@@ -84,9 +84,17 @@ jobs:
|
||||
- name: Update Env Files
|
||||
run: |
|
||||
VERSION="${{ steps.deploy-trigger.outputs.deploymentVersion }}"
|
||||
echo "TRIGGER_VERSION=$VERSION" > apps/web/.env.production
|
||||
echo "TRIGGER_VERSION=$VERSION" > apps/api/v2/.env.production
|
||||
git add -f apps/web/.env.production apps/api/v2/.env.production
|
||||
|
||||
# Create the TypeScript content
|
||||
CONTENT="export const TRIGGER_VERSION = '$VERSION';"
|
||||
|
||||
# Write to the specific paths
|
||||
echo "$CONTENT" > apps/web/trigger.version.ts
|
||||
echo "$CONTENT" > apps/api/v2/trigger.version.ts
|
||||
echo "$CONTENT" > apps/api/v1/trigger.version.js
|
||||
|
||||
# Force add to git since these are likely in .gitignore
|
||||
git add -f apps/web/trigger.version.ts apps/api/v2/trigger.version.ts apps/api/v1/trigger.version.js
|
||||
|
||||
# 4. Commit everything together
|
||||
- name: Commit changes
|
||||
|
||||
@@ -38,7 +38,6 @@ yarn-error.log*
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.appStore.example
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
const { PrismaPlugin } = require("@prisma/nextjs-monorepo-workaround-plugin");
|
||||
|
||||
const { TRIGGER_VERSION } = require("./trigger.version.js");
|
||||
const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
@@ -105,4 +105,10 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
);
|
||||
}
|
||||
|
||||
const env = process.env;
|
||||
|
||||
if (process.env.NODE_ENV === "production" || process.env.CALCOM_ENV === "production") {
|
||||
env.TRIGGER_VERSION = TRIGGER_VERSION;
|
||||
}
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const TRIGGER_VERSION = '20260129.1';
|
||||
@@ -17,7 +17,8 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../../../packages/types/*.d.ts",
|
||||
"../../../packages/types/next-auth.d.ts"
|
||||
"../../../packages/types/next-auth.d.ts",
|
||||
"trigger.version.js"
|
||||
],
|
||||
"exclude": ["**/node_modules/**", "templates", "auth"]
|
||||
}
|
||||
|
||||
+36
-10
@@ -1,14 +1,34 @@
|
||||
NODE_ENV=
|
||||
API_PORT=
|
||||
API_URL=
|
||||
DATABASE_READ_URL=
|
||||
DATABASE_WRITE_URL=
|
||||
LOG_LEVEL=
|
||||
NEXTAUTH_SECRET=
|
||||
DATABASE_URL=
|
||||
JWT_SECRET=
|
||||
# these are the environment variables you absolutely need for api v2 to work
|
||||
# NODE_ENV, API_PORT, REDIS_URL, DATABASE_URL, DATABASE_READ_URL, DATABASE_WRITE_URL, NEXTAUTH_SECRET, JWT_SECRET, STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, CALENDSO_ENCRYPTION_KEY
|
||||
|
||||
# we set environmnet as development so that right environment file is loaded and swagger is generated
|
||||
NODE_ENV="development"
|
||||
API_PORT=5555
|
||||
API_URL="http://localhost"
|
||||
# url to Postgres running in Docker. Postgres is setup in packages/prisma/docker-compose.yml
|
||||
DATABASE_READ_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
DATABASE_WRITE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
LOG_LEVEL=DEBUG
|
||||
# the same value as in the root .env - it is used by api-auth.strategy.ts to validate requests authenticated with next token. If you already have a value
|
||||
# in the root .env then you can paste it here. It is important that they are the same.
|
||||
NEXTAUTH_SECRET="RbyBOfiZFt/IsBL2E2Zy2idcKRH+WclnHEiu1FmRdf0="
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
DATABASE_DIRECT_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
# used by JwtService to sign and decode tokens
|
||||
JWT_SECRET="asjdijI1JIO12I3O89198jojioSAJDU"
|
||||
SENTRY_DSN=
|
||||
|
||||
# mailhog setup. Make sure its running - see instructions in apps/api/v2/README.md
|
||||
EMAIL_SERVER_HOST='localhost'
|
||||
EMAIL_SERVER_PORT=1025
|
||||
|
||||
# used to manage encrypt and decrypt third party tokens
|
||||
CALENDSO_ENCRYPTION_KEY="Lmqy57DlHKPvwwrtkwjL/8QtxJWxC/OV"
|
||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY="LDBFYLEVICRZBYQZUZCSTZRJFZUKZMEI"
|
||||
# url to redis instance running in Docker and used to cache data. Redis is setup in apps/api/v2/docker-compose.yaml
|
||||
REDIS_URL="rediss://localhost:6379"
|
||||
|
||||
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
@@ -19,13 +39,19 @@ STRIPE_PRICE_ID_ESSENTIALS=
|
||||
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
|
||||
STRIPE_PRICE_ID_ENTERPRISE=
|
||||
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
|
||||
# since these two values are required for api v2 to start, you can put a dummy value here if you don't need Stripe functionality
|
||||
# this can be any random string value eg: STRIPE_API_KEY="sk_test_1234567890" and STRIPE_WEBHOOK_SECRET="whsec_1234567890"
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# used to know where to direct people when managing platform billing
|
||||
WEB_APP_URL=http://localhost:3000/
|
||||
# to setup license key see apps/api/v2/README.md
|
||||
CALCOM_LICENSE_KEY=
|
||||
# when request is authenticated with an api key we need to remove the prefix. See code in api-auth.strategy.ts
|
||||
API_KEY_PREFIX=cal_
|
||||
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
||||
GET_LICENSE_KEY_URL="https://goblin.cal.com/v1/license"
|
||||
# we disable workers when running e2e and enable them while running locally
|
||||
IS_E2E=false
|
||||
DOCS_URL=
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
TRIGGER_VERSION=20260128.2
|
||||
@@ -44,3 +44,6 @@ lerna-debug.log*
|
||||
!.env.appStore.example
|
||||
/client/*
|
||||
/generated/prisma/*
|
||||
|
||||
# Generated secrets file
|
||||
.generated-secrets
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Local development
|
||||
|
||||
## Local development - part 1
|
||||
This setup will allow you to develop with api v2 locally and also test platform atoms with the examples app.
|
||||
|
||||
Open `apps/api/v2/README.md` and do all the steps except step 6 where you setup prisma.
|
||||
|
||||
## Local development - part 2 (platform variables shared between api v2 and examples app)
|
||||
We now need to setup platform OAuth client and OAuth 2.0 client that can be tested in the platform example app `packages/platform/examples/base`.
|
||||
|
||||
### If you want to create clients by seeding the database automatically
|
||||
|
||||
1. Setup root environment that are re-used in the root `.env` and in the platform examples app - open the root `.env` and set values for the platform OAuth client. The id and secret can be random values.
|
||||
```
|
||||
SEED_PLATFORM_OAUTH_CLIENT_ID=
|
||||
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
|
||||
```
|
||||
Then you need to copy these values to the platform examples app environment.
|
||||
- Rename `packages/platform/examples/base/.env.example` to `packages/platform/examples/base/.env`. There will be many environment variables already populated to make it easier for you.
|
||||
- copy the value of `SEED_PLATFORM_OAUTH_CLIENT_ID` into `packages/platform/examples/base/.env` `NEXT_PUBLIC_X_CAL_ID`
|
||||
- copy the value of `SEED_PLATFORM_OAUTH_CLIENT_SECRET` into `packages/platform/examples/base/.env` `X_CAL_SECRET_KEY`
|
||||
|
||||
Then, we need to setup platform OAuth 2.0 client. We will need an id, and then hashed and plain secrets. To generate them you can:
|
||||
```
|
||||
cd apps/api/v2
|
||||
yarn generate-secrets
|
||||
```
|
||||
|
||||
and then the secrets will be written to `apps/api/v2/.generated-secrets` file containing:
|
||||
```
|
||||
plain - <PLAINTEXT_SECRET>
|
||||
hashed - <HASHED_SECRET>
|
||||
```
|
||||
|
||||
In the root `.env` set the following environment variables:
|
||||
```
|
||||
SEED_OAUTH2_CLIENT_ID=
|
||||
SEED_OAUTH2_CLIENT_SECRET_HASHED=
|
||||
```
|
||||
where CLIENT_ID can be random but SECRET_HASHED is hashed secret above.
|
||||
Then in the `packages/platform/examples/base/.env` set the `NEXT_PUBLIC_OAUTH2_CLIENT_ID` to be equal to `SEED_OAUTH2_CLIENT_ID` and `OAUTH2_CLIENT_SECRET_PLAIN` to the plain secret generated above.
|
||||
|
||||
|
||||
### If you want to create clients manually
|
||||
1. Create new oauth client in the PlatformOAuthClient table manually. Here is example json. You can set id and secret to random values.
|
||||
```
|
||||
{
|
||||
"id": "clxyyy21o0003sbk7yw5z6tzg",
|
||||
"name": "Acme",
|
||||
"secret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWNtZSAiLCJwZXJtaXNzaW9ucyI6MTAyMywicmVkaXJlY3RVcmlzIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NDMyMSJdLCJib29raW5nUmVkaXJlY3RVcmkiOiIiLCJib29raW5nQ2FuY2VsUmVkaXJlY3RVcmkiOiIiLCJib29raW5nUmVzY2hlZHVsZVJlZGlyZWN0VXJpIjoiIiwiYXJlRW1haWxzRW5hYmxlZCI6dHJ1ZSwiaWF0IjoxNzE5NTk1ODA4fQ.L5_jSS14fcKLCD_9_DAOgtGd6lUSZlU5CEpCPaPt41I",
|
||||
"permissions": 1023,
|
||||
"logo": null,
|
||||
"redirectUris": "{http://localhost:4321}",
|
||||
"organizationId": 1,
|
||||
"createdAt": "2026-01-27 09:35:27.297",
|
||||
"areEmailsEnabled": true,
|
||||
"bookingCancelRedirectUri": null,
|
||||
"bookingRedirectUri": null,
|
||||
"bookingRescheduleRedirectUri": null,
|
||||
"areDefaultEventTypesEnabled": true,
|
||||
"areCalendarEventsEnabled": true
|
||||
}
|
||||
```
|
||||
Then you need to copy id and secret to the platform examples app environment.
|
||||
- Rename `packages/platform/examples/base/.env.example` to `packages/platform/examples/base/.env`
|
||||
- copy the client id into `packages/platform/examples/base/.env` `NEXT_PUBLIC_X_CAL_ID`
|
||||
- copy the client secret into `packages/platform/examples/base/.env` `X_CAL_SECRET_KEY`
|
||||
|
||||
Then, we need to setup platform OAuth 2.0 client. We will need an id, and then hashed and plain secrets. To generate them you can:
|
||||
```
|
||||
cd apps/api/v2
|
||||
yarn generate-secrets
|
||||
```
|
||||
|
||||
and then the secrets will be written to `apps/api/v2/.generated-secrets` file containing:
|
||||
```
|
||||
plain - <PLAINTEXT_SECRET>
|
||||
hashed - <HASHED_SECRET>
|
||||
```
|
||||
|
||||
Then in the `packages/platform/examples/base/.env` set the `NEXT_PUBLIC_OAUTH2_CLIENT_ID` to be equal to a random id and `OAUTH2_CLIENT_SECRET_PLAIN` to the plain secret generated above.
|
||||
|
||||
Then create a new entry in the OAuthClient table with the random id and hashed secret. It is important that the redirectUri is like below because that is where the examples app is running.
|
||||
```
|
||||
{
|
||||
"clientId": "1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517",
|
||||
"redirectUri": "http://localhost:4321",
|
||||
"clientSecret": "970db2cf14112013ba3a510b945294fef8737d42ee58c32031d2351692068ce7",
|
||||
"name": "atoms examples app oauth 2 client",
|
||||
"logo": null,
|
||||
"clientType": "confidential",
|
||||
"isTrusted": false,
|
||||
"createdAt": "2026-01-27 09:35:26.731",
|
||||
"purpose": "test atoms examples app with oauth 2",
|
||||
"rejectionReason": null,
|
||||
"status": "approved",
|
||||
"userId": 10,
|
||||
"websiteUrl": "http://localhost:4321"
|
||||
}
|
||||
```
|
||||
I point userId to 10 aka `admin@example.com`.
|
||||
|
||||
## Local development - part 3
|
||||
Proceed to `apps/api/v2/README.md` step 6 to setup prisma and seed database if you need to.
|
||||
|
||||
## Local development - part 4 (Stripe)
|
||||
If you want to test platform billing locally then please proceed to `packages/platform/atoms/STRIPE.md` tutorial.
|
||||
|
||||
## Local development - part 5
|
||||
If you followed steps above then examples app environment is setup.
|
||||
|
||||
If you also want to test apple connect calendar atom, then you need to create app specific password using your apple email and
|
||||
set the following variables:
|
||||
```
|
||||
ATOMS_E2E_APPLE_ID=
|
||||
ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE=
|
||||
```
|
||||
|
||||
Finally, if you want that the examples app is tested using OAuth 2.0 client then you must set environment variable
|
||||
`NEXT_PUBLIC_OAUTH2_MODE="true"` in `packages/platform/examples/base/.env`. If you want to test it using platform OAuth client then
|
||||
comment this variable or set to false.
|
||||
|
||||
# Running everything
|
||||
1. Build atoms for local development:
|
||||
cd packages/platform/atoms
|
||||
yarn dev-on
|
||||
yarn build-npm
|
||||
```
|
||||
The dev-on command changes how atoms are built. Remember to run `yarn dev-off` when you are done testing atoms locally. If you update anything related to frontend, for example, Booker, then you need to run `yarn build-npm` again to rebuild atoms.
|
||||
|
||||
2. Start api v2:
|
||||
```
|
||||
Sometimes it happens that v2 api restarts because some unrelated log of build files changed, and if it makes examples app not wor you can just start it using:
|
||||
```
|
||||
cd apps/api/v2
|
||||
yarn dev:build
|
||||
yarn start
|
||||
```
|
||||
|
||||
3. Setups examples app sqlite database.
|
||||
```
|
||||
cd packages/platform/examples/base
|
||||
rm -f prisma/dev.db && yarn prisma db push
|
||||
```
|
||||
4. Run examples app and open localhost:4321
|
||||
```
|
||||
cd packages/platform/examples/base
|
||||
yarn dev
|
||||
```
|
||||
|
||||
If you have set `NEXT_PUBLIC_OAUTH2_MODE="true"` then here is how to test it:
|
||||
|
||||
If you do test OAuth 2.0 using atoms then do below:
|
||||
1. Start cal web app locally by running `yarn dev` in the root folder and login using any user into cal web app running locally.
|
||||
2. Using client id and redirect_uri information above create /authorize url. Example http://localhost:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas or if you have setup localhost:3000 to map to app.cal.local http://app.cal.local:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas and authorize test OAuth client. You will be redirected to localhost:4321?code=abc and this route will exchange the authorization code for access and refresh tokens for the logged in cal user and store them in the examples app SQLite database.
|
||||
3. Examples app is ready to use. If you update, let's say an availability, then it will be reflected in the availability of admin@example.com in the web app running locally.
|
||||
+45
-88
@@ -1,95 +1,68 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/user-attachments/assets/ce7c2ecf-6097-469a-8512-c846a9fb665d" height="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
Cal.com api v2 is a [Nest.js](https://github.com/nestjs/nest) project.
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
Cal.com is using the [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Installation
|
||||
# Local development
|
||||
This setup will allow you to develop with api v2 locally. If you want to also test atoms locally with platform's example app,
|
||||
then proceed to `apps/api/v2/README-PLATFORM.md` instead.
|
||||
|
||||
1. Install dependencies
|
||||
```bash
|
||||
$ yarn install
|
||||
```
|
||||
|
||||
## Prisma setup
|
||||
|
||||
2. Download and install Docker on your computer and then make sure it is running. You simply need to open Docker dashboard.
|
||||
3. Make sure that mailhog is running - it is used by v2 to send emails locally. You can start it by:
|
||||
```bash
|
||||
$ yarn prisma generate
|
||||
$ cd packages/emails && yarn dx
|
||||
```
|
||||
4. Setup api v2 environment - make a copy of `apps/api/v2/.env.example` and rename it to `apps/api/v2/.env` - it has almost all the required values setup - ones you need to add will be explained below.
|
||||
- Then copy the value of `NEXTAUTH_SECRET` from there to the root `.env` `NEXTAUTH_SECRET`. If you have `NEXTAUTH_SECRET` already in the root `.env` then you can paste that value in `apps/api/v2/.env`.
|
||||
```
|
||||
Note: make sure that the value of `NEXTAUTH_SECRET` is the same in both the root `.env` and in the api v2 `.env`.
|
||||
```
|
||||
5. Setup license key. In the Deployment table in database create the following entry:
|
||||
```
|
||||
|
||||
## Env setup
|
||||
|
||||
Copy `.env.example` to `.env` and fill values.
|
||||
|
||||
## Add license Key to Deployment table in DB
|
||||
|
||||
id, logo, theme, licenseKey, agreedLicenseAt:-
|
||||
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
|
||||
|
||||
Replace with your actual license key.
|
||||
|
||||
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||
|
||||
.env
|
||||
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
|
||||
|
||||
## Running the app
|
||||
|
||||
# Development
|
||||
|
||||
```
|
||||
Then in the `apps/api/v2/.env` set the license key environment variable:
|
||||
```
|
||||
CALCOM_LICENSE_KEY="00000000-0000-0000-0000-000000000000"
|
||||
```
|
||||
6. (optional) Prisma setup and database seeding - if you need to setup and seed database you can do it:
|
||||
```bash
|
||||
$ yarn run start
|
||||
$ cd packages/prisma
|
||||
$ yarn prisma generate
|
||||
$ yarn prisma migrate dev
|
||||
$ yarn db-seed
|
||||
```
|
||||
7. Proceed to the next section to start api v2
|
||||
|
||||
# Running api v2
|
||||
|
||||
Start api v2 using:
|
||||
```bash
|
||||
$ yarn dev
|
||||
```
|
||||
|
||||
OR if you don't want to use docker, you can run following command.
|
||||
|
||||
```bash
|
||||
$ yarn dev:no-docker
|
||||
Sometimes it happens that v2 api restarts because some unrelated log of build files changed if you are running it while cal web app is running. If it happens and is annoying you, you can just build it and then run without watch mode:
|
||||
```
|
||||
cd apps/api/v2
|
||||
yarn dev:build
|
||||
yarn start
|
||||
```
|
||||
|
||||
Additionally you can run following command(in different terminal) to ensure that any change in any of the dependencies is rebuilt and detected. It watches platform-libraries, platform-constants, platform-enums, platform-utils, platform-types.
|
||||
Api v2 depends on various platform packages "platform-libraries, platform-constants, platform-enums, platform-utils, platform-types" so if any of them change you might need to restart api v2 so it rebuild these dependencies and picks up the changes.
|
||||
|
||||
Notably, you can run following command(in different terminal) to ensure that any change in any of the dependencies is rebuilt and detected. It watches platform-libraries, platform-constants, platform-enums, platform-utils, platform-types.
|
||||
|
||||
```bash
|
||||
$ yarn run dev:build:watch
|
||||
```
|
||||
|
||||
If you are making changes in packages/platform/libraries, you should run the following command too that would connect your local packages/platform/libraries to the api/v2
|
||||
|
||||
OR if you don't want to use docker, you can run following command.
|
||||
```bash
|
||||
$ yarn local
|
||||
$ yarn dev:no-docker
|
||||
```
|
||||
|
||||
# watch mode
|
||||
$ yarn run start:dev
|
||||
|
||||
# production mode
|
||||
$ yarn run start:prod
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
@@ -99,17 +72,14 @@ $ yarn run test
|
||||
# e2e tests
|
||||
$ yarn run test:e2e
|
||||
|
||||
# e2e tests in watch mode
|
||||
$ yarn test:e2e:watch
|
||||
|
||||
# run specific e2e test file in watch mode
|
||||
$ yarn test:e2e:watch --testPathPattern=filePath
|
||||
$ yarn run test:e2e some-file.e2e-spec.ts
|
||||
|
||||
# test coverage
|
||||
$ yarn run test:cov
|
||||
```
|
||||
|
||||
## Conventions
|
||||
# Conventions
|
||||
|
||||
### Guards
|
||||
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||
@@ -117,16 +87,3 @@ $ yarn run test:cov
|
||||
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
|
||||
all auth methods are allowed.
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](LICENSE).
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
|
||||
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
|
||||
"generate-secrets": "ts-node scripts/generate-secrets.ts",
|
||||
"prepare": "yarn run snyk-protect",
|
||||
"snyk-protect": "snyk-protect"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { generateSecret } from "@calcom/platform-libraries";
|
||||
|
||||
const [hashed, plain] = generateSecret();
|
||||
|
||||
const outputPath = path.join(__dirname, "..", ".generated-secrets");
|
||||
const content = `plain - ${plain}\nhashed - ${hashed}\n`;
|
||||
|
||||
fs.writeFileSync(outputPath, content);
|
||||
|
||||
process.stdout.write(`Secrets written to: ${outputPath}\n`);
|
||||
@@ -1,6 +1,4 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { Controller, Get, VERSION_NEUTRAL, Version } from "@nestjs/common";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup";
|
||||
import { AppController } from "./app.controller";
|
||||
import appConfig from "@/config/app";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
@@ -13,23 +21,13 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
import { VercelWebhookController } from "@/vercel-webhook.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? { envFilePath: ".env.production" }
|
||||
: { ignoreEnvFile: true }),
|
||||
ignoreEnvFile: true,
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
@@ -61,7 +59,7 @@ import { AppController } from "./app.controller";
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
controllers: [AppController, VercelWebhookController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
@@ -96,6 +94,10 @@ export class AppModule implements NestModule {
|
||||
{
|
||||
path: "/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
},
|
||||
{
|
||||
path: "/v2/webhooks/vercel/deployment-promoted",
|
||||
method: RequestMethod.POST,
|
||||
}
|
||||
)
|
||||
.apply(JsonBodyMiddleware)
|
||||
|
||||
@@ -15,11 +15,11 @@ import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Request } from "express";
|
||||
import helmet from "helmet";
|
||||
import { CalendarServiceExceptionFilter } from "./filters/calendar-service-exception.filter";
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import { CalendarServiceExceptionFilter } from "./filters/calendar-service-exception.filter";
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
|
||||
const logger: Logger = new Logger("Bootstrap");
|
||||
|
||||
|
||||
@@ -2667,6 +2667,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
jest
|
||||
.spyOn(EventManager.prototype, "create")
|
||||
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
|
||||
jest
|
||||
.spyOn(EventManager.prototype, "createAllCalendarEvents")
|
||||
.mockImplementation(() => Promise.resolve([]));
|
||||
jest
|
||||
.spyOn(EventManager.prototype, "createAllCRMEvents")
|
||||
.mockImplementation(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
describe("platform oAuth client has calendar events enabled", () => {
|
||||
@@ -2837,7 +2843,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
@@ -2867,7 +2874,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2901,7 +2909,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||
bookingThatRequiresConfirmationUid = data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -2926,7 +2935,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
@@ -9,12 +8,15 @@ import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { WinstonModule } from "nest-winston";
|
||||
import qs from "qs";
|
||||
import type { AppConfig } from "@/config/type";
|
||||
|
||||
import { TRIGGER_VERSION } from "../trigger.version";
|
||||
import { AppModule } from "./app.module";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { loggerConfig } from "./lib/logger";
|
||||
import type { AppConfig } from "@/config/type";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
process.env.TRIGGER_VERSION = TRIGGER_VERSION;
|
||||
}
|
||||
const logger: Logger = new Logger("App");
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ export class OrganizationsOrganizationsController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan(SCALE)
|
||||
@Delete("/:managedOrganizationId")
|
||||
@Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "organizations_delete" })
|
||||
@Throttle({ limit: 1, ttl: 1000, blockDuration: 1000, name: "organizations_delete" })
|
||||
@ApiOperation({
|
||||
summary: "Delete an organization within an organization",
|
||||
description:
|
||||
|
||||
@@ -121,7 +121,7 @@ export class OrganizationsTeamsController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/:teamId")
|
||||
@Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "org_teams_delete" })
|
||||
@Throttle({ limit: 1, ttl: 1000, blockDuration: 1000, name: "org_teams_delete" })
|
||||
@ApiOperation({ summary: "Delete a team" })
|
||||
async deleteTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
|
||||
@@ -98,7 +98,7 @@ export class TeamsController {
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Delete("/:teamId")
|
||||
@Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "teams_delete" })
|
||||
@Throttle({ limit: 1, ttl: 1000, blockDuration: 1000, name: "teams_delete" })
|
||||
@ApiOperation({ summary: "Delete a team" })
|
||||
@Roles("TEAM_OWNER")
|
||||
async deleteTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise<OrgTeamOutputResponseDto> {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import type { INestApplication } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { bootstrap } from "@/bootstrap";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
|
||||
describe("Vercel Webhook Controller (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
const WEBHOOK_SECRET = "test-webhook-secret";
|
||||
const TRIGGER_VERSION = "test-version-123";
|
||||
const TRIGGER_SECRET_KEY = "test-trigger-secret-key";
|
||||
|
||||
beforeAll(async () => {
|
||||
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
|
||||
process.env.VERCEL_PROMOTE_WEBHOOK_SECRET = WEBHOOK_SECRET;
|
||||
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
|
||||
process.env.TRIGGER_VERSION = TRIGGER_VERSION;
|
||||
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
|
||||
process.env.TRIGGER_SECRET_KEY = TRIGGER_SECRET_KEY;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
|
||||
delete process.env.VERCEL_PROMOTE_WEBHOOK_SECRET;
|
||||
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
|
||||
delete process.env.TRIGGER_VERSION;
|
||||
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
|
||||
delete process.env.TRIGGER_SECRET_KEY;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function generateSignature(payload: object): string {
|
||||
// Compute signature from the JSON stringified payload
|
||||
// This matches how the guard will re-stringify the parsed object
|
||||
return crypto.createHmac("sha1", WEBHOOK_SECRET).update(JSON.stringify(payload)).digest("hex");
|
||||
}
|
||||
|
||||
describe("POST /webhooks/vercel/deployment-promoted", () => {
|
||||
it("should reject requests without x-vercel-signature header", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/v2/webhooks/vercel/deployment-promoted")
|
||||
.send({ type: "deployment.promoted" })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should reject requests with invalid signature", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/v2/webhooks/vercel/deployment-promoted")
|
||||
.set("x-vercel-signature", "0000000000000000000000000000000000000000")
|
||||
.send({ type: "deployment.promoted" })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should ignore non-deployment.promoted events with valid signature", async () => {
|
||||
const payload = { type: "deployment.created" };
|
||||
const signature = generateSignature(payload);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/webhooks/vercel/deployment-promoted")
|
||||
.set("x-vercel-signature", signature)
|
||||
.send(payload)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("ignored");
|
||||
expect(response.body.version).toBe("");
|
||||
});
|
||||
|
||||
it("should process deployment.promoted events with valid signature", async () => {
|
||||
const payload = { type: "deployment.promoted" };
|
||||
const signature = generateSignature(payload);
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalled = false;
|
||||
let fetchUrl = "";
|
||||
let fetchOptions: RequestInit | undefined;
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
|
||||
fetchCalled = true;
|
||||
fetchUrl = url;
|
||||
fetchOptions = options;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/webhooks/vercel/deployment-promoted")
|
||||
.set("x-vercel-signature", signature)
|
||||
.send(payload)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("success");
|
||||
expect(response.body.version).toBe(TRIGGER_VERSION);
|
||||
expect(fetchCalled).toBe(true);
|
||||
expect(fetchUrl).toBe(`https://api.trigger.dev/api/v1/deployments/${TRIGGER_VERSION}/promote`);
|
||||
expect(fetchOptions?.method).toBe("POST");
|
||||
expect(fetchOptions?.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
Authorization: `Bearer ${TRIGGER_SECRET_KEY}`,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("should retry on trigger.dev API failure", async () => {
|
||||
const payload = { type: "deployment.promoted" };
|
||||
const signature = generateSignature(payload);
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCallCount = 0;
|
||||
|
||||
global.fetch = jest.fn().mockImplementation(() => {
|
||||
fetchCallCount++;
|
||||
if (fetchCallCount < 3) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal Server Error"),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/webhooks/vercel/deployment-promoted")
|
||||
.set("x-vercel-signature", signature)
|
||||
.send(payload)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("success");
|
||||
expect(fetchCallCount).toBe(3);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { VercelWebhookGuard } from "./vercel-webhook.guard";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
|
||||
interface VercelWebhookPayload {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
@Controller({
|
||||
path: "/v2/webhooks/vercel",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@ApiExcludeController(true)
|
||||
export class VercelWebhookController {
|
||||
private readonly logger = new Logger(VercelWebhookController.name);
|
||||
|
||||
@Post("deployment-promoted")
|
||||
@UseGuards(VercelWebhookGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async handlePromotion(
|
||||
@Body() rawBody: Buffer | VercelWebhookPayload
|
||||
): Promise<{ status: string; version: string }> {
|
||||
// RawBodyMiddleware provides a Buffer, need to parse it
|
||||
// In test environment, body may already be parsed as object
|
||||
let payload: VercelWebhookPayload;
|
||||
if (Buffer.isBuffer(rawBody)) {
|
||||
try {
|
||||
payload = JSON.parse(rawBody.toString("utf-8")) as VercelWebhookPayload;
|
||||
} catch {
|
||||
this.logger.error("Failed to parse webhook payload as JSON");
|
||||
return { status: "error", version: "" };
|
||||
}
|
||||
} else {
|
||||
payload = rawBody;
|
||||
}
|
||||
|
||||
if (payload.type !== "deployment.promoted") {
|
||||
this.logger.log(`Ignoring webhook event type: ${payload.type}`);
|
||||
return { status: "ignored", version: "" };
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noProcessEnv: Environment variable access required for trigger.dev API
|
||||
const TRIGGER_VERSION = process.env.TRIGGER_VERSION;
|
||||
|
||||
if (!TRIGGER_VERSION) {
|
||||
this.logger.error("TRIGGER_VERSION is not defined");
|
||||
throw new UnauthorizedException("Trigger.dev configuration error");
|
||||
}
|
||||
|
||||
this.logger.log(`Vercel Promoted! Promoting Trigger.dev to: ${TRIGGER_VERSION}`);
|
||||
|
||||
await this.promoteTriggerDeployment(TRIGGER_VERSION);
|
||||
|
||||
return { status: "success", version: TRIGGER_VERSION };
|
||||
}
|
||||
|
||||
private async promoteTriggerDeployment(version: string, maxRetries: number = 3): Promise<void> {
|
||||
// biome-ignore lint/style/noProcessEnv: Environment variable access required for trigger.dev API
|
||||
const triggerSecretKey = process.env.TRIGGER_SECRET_KEY;
|
||||
|
||||
const url = `https://api.trigger.dev/api/v1/deployments/${version}/promote`;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${triggerSecretKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Trigger.dev API Error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully promoted Trigger.dev deployment: ${version}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
let errorMessage = "Unknown error";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
this.logger.error(`Promotion attempt ${attempt} failed: ${errorMessage}`);
|
||||
if (attempt === maxRetries) throw error;
|
||||
|
||||
// Exponential backoff: 2s, 4s, 8s
|
||||
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class VercelWebhookGuard implements CanActivate {
|
||||
private readonly logger = new Logger(VercelWebhookGuard.name);
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const signature = request.headers["x-vercel-signature"];
|
||||
// biome-ignore lint/style/noProcessEnv: Environment variable access required for webhook secret
|
||||
const webhookSecret = process.env.VERCEL_PROMOTE_WEBHOOK_SECRET;
|
||||
|
||||
if (!webhookSecret) {
|
||||
this.logger.error("VERCEL_PROMOTE_WEBHOOK_SECRET is not defined");
|
||||
throw new UnauthorizedException("Webhook configuration error");
|
||||
}
|
||||
|
||||
if (!signature) {
|
||||
throw new UnauthorizedException("Missing x-vercel-signature header");
|
||||
}
|
||||
|
||||
// Get raw body from RawBodyMiddleware (Buffer) or parsed body (object in test env)
|
||||
const rawBody = request.body;
|
||||
|
||||
if (!rawBody) {
|
||||
this.logger.error("Request body is empty. Ensure RawBodyMiddleware is active.");
|
||||
throw new UnauthorizedException("Invalid request format");
|
||||
}
|
||||
|
||||
// Determine the body bytes for signature verification
|
||||
// In production: RawBodyMiddleware provides a Buffer
|
||||
// In tests: body may be a parsed object, convert back to JSON string
|
||||
let bodyForSignature: Buffer | string;
|
||||
if (Buffer.isBuffer(rawBody)) {
|
||||
bodyForSignature = rawBody;
|
||||
} else if (typeof rawBody === "string") {
|
||||
bodyForSignature = rawBody;
|
||||
} else {
|
||||
// Object from parsed JSON - convert back to string (test environment)
|
||||
bodyForSignature = JSON.stringify(rawBody);
|
||||
}
|
||||
|
||||
const expectedSignature = crypto.createHmac("sha1", webhookSecret).update(bodyForSignature).digest("hex");
|
||||
|
||||
// Check signature length first (timingSafeEqual throws if lengths differ)
|
||||
if (signature.length !== expectedSignature.length) {
|
||||
this.logger.warn("Invalid Vercel webhook signature length");
|
||||
throw new UnauthorizedException("Invalid signature");
|
||||
}
|
||||
|
||||
// Secure comparison to prevent timing attacks
|
||||
const isSignatureValid = crypto.timingSafeEqual(
|
||||
Buffer.from(expectedSignature, "utf8"),
|
||||
Buffer.from(signature, "utf8")
|
||||
);
|
||||
|
||||
if (!isSignatureValid) {
|
||||
this.logger.warn("Invalid Vercel webhook signature");
|
||||
throw new UnauthorizedException("Invalid signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TRIGGER_VERSION = '20260129.1';
|
||||
@@ -1 +0,0 @@
|
||||
TRIGGER_VERSION=20260128.2
|
||||
@@ -1,6 +1,6 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
|
||||
import { FlagListingView } from "@calcom/web/modules/feature-flags/views/flag-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z, ZodError } from "zod";
|
||||
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { PrismaPendingRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
@@ -58,6 +59,18 @@ export const queuedResponseHandler = async ({
|
||||
chosenRouteId: queuedFormResponse.chosenRouteId,
|
||||
});
|
||||
|
||||
// Link the pending routing trace to the new formResponseId so it can be found when booking is created
|
||||
try {
|
||||
const pendingTraceRepo = new PrismaPendingRoutingTraceRepository(prisma);
|
||||
await pendingTraceRepo.linkToFormResponse({
|
||||
queuedFormResponseId: queuedFormResponse.id,
|
||||
formResponseId: formResponse.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't fail - trace linking is not critical
|
||||
logger.warn("Failed to link pending routing trace to form response", safeStringify(error));
|
||||
}
|
||||
|
||||
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
|
||||
await onSubmissionOfFormResponse({
|
||||
form: {
|
||||
|
||||
@@ -133,7 +133,7 @@ async function getHandler(req: NextRequest) {
|
||||
|
||||
// redirect to team screen
|
||||
return NextResponse.redirect(
|
||||
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
|
||||
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, WEBAPP_URL),
|
||||
{ status: 302 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const DestinationCalendarSettingsWebWrapper = ({
|
||||
};
|
||||
|
||||
const validatedReminderValue = reminderSchema.safeParse(
|
||||
calendars.data.destinationCalendar.customCalendarReminder
|
||||
calendars.data.destinationCalendar?.customCalendarReminder
|
||||
);
|
||||
let reminderValue: ReminderMinutes = null;
|
||||
if (validatedReminderValue.success) {
|
||||
@@ -63,7 +63,7 @@ export const DestinationCalendarSettingsWebWrapper = ({
|
||||
connectedCalendars={calendars.data.connectedCalendars}
|
||||
isPending={mutation.isPending}
|
||||
destinationCalendar={calendars.data.destinationCalendar}
|
||||
value={calendars.data.destinationCalendar.externalId}
|
||||
value={calendars.data.destinationCalendar?.externalId}
|
||||
hidePlaceholder
|
||||
onChange={mutation.mutate}
|
||||
onReminderChange={handleReminderChange}
|
||||
|
||||
@@ -6,7 +6,7 @@ interface IConnectedCalendarItem {
|
||||
externalId?: string;
|
||||
integrationType: string;
|
||||
calendars?: {
|
||||
primary: true | null;
|
||||
primary: boolean | null;
|
||||
isSelected: boolean;
|
||||
credentialId: number;
|
||||
name?: string | undefined;
|
||||
@@ -18,7 +18,7 @@ interface IConnectedCalendarItem {
|
||||
}[];
|
||||
}
|
||||
|
||||
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
const ConnectedCalendarItem = (prop: IConnectedCalendarItem): JSX.Element => {
|
||||
const { name, logo, externalId, calendars, integrationType } = prop;
|
||||
|
||||
return (
|
||||
@@ -26,7 +26,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
<div className="flex flex-row items-center p-4">
|
||||
<img src={logo} alt={name} className="m-1 h-8 w-8" />
|
||||
<div className="mx-4">
|
||||
<p className="font-sans text-sm font-bold leading-5">
|
||||
<p className="font-bold font-sans text-sm leading-5">
|
||||
{name}
|
||||
{/* Temporarily removed till we use it on another place */}
|
||||
{/* <span className="mx-1 rounded-[4px] bg-cal-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
|
||||
@@ -36,7 +36,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
<div className="fle-row flex">
|
||||
<span
|
||||
title={externalId}
|
||||
className="max-w-44 text-subtle mt-1 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm">
|
||||
className="mt-1 max-w-44 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm text-subtle">
|
||||
{externalId}{" "}
|
||||
</span>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
{t("edit")}
|
||||
</Button> */}
|
||||
</div>
|
||||
<div className="border-subtle h-px w-full border-b" />
|
||||
<div className="h-px w-full border-subtle border-b" />
|
||||
<div>
|
||||
<ul className="p-4">
|
||||
{calendars?.map((calendar, i) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterInputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Schedule from "@calcom/web/modules/schedules/components/Schedule";
|
||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -231,6 +231,7 @@ export const OAuthClientForm = ({
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-subtle mt-2 text-sm">{t("calendar_events_disabled_video_limitation")}</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -11,7 +11,7 @@ import DynamicPostHogProvider from "~/ee/posthog/providerDynamic";
|
||||
import DynamicPostHogPageView from "~/ee/posthog/pageViewDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
|
||||
import DynamicHelpscoutProvider from "@calcom/web/modules/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/web/modules/ee/support/lib/intercom/providerDynamic";
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useEffect } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
|
||||
import DynamicHelpscoutProvider from "@calcom/web/modules/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/web/modules/ee/support/lib/intercom/providerDynamic";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import posthog from "posthog-js";
|
||||
|
||||
import { BulkEditDefaultForEventsModal } from "@calcom/web/modules/event-types/components/BulkEditDefaultForEventsModal";
|
||||
import type { BulkUpdatParams } from "@calcom/web/modules/event-types/components/BulkEditDefaultForEventsModal";
|
||||
import { NewScheduleButton } from "@calcom/features/schedules/components/NewScheduleButton";
|
||||
import { NewScheduleButton } from "@calcom/web/modules/schedules/components/NewScheduleButton";
|
||||
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -36,7 +36,7 @@ type AuditLog = {
|
||||
source: string;
|
||||
displayJson?: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
|
||||
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
|
||||
actor: {
|
||||
type: AuditActorType;
|
||||
displayName: string | null;
|
||||
@@ -74,8 +74,7 @@ const ACTION_ICON_MAP: Record<string, IconName> = {
|
||||
ATTENDEE_ADDED: "user-check",
|
||||
ATTENDEE_REMOVED: "user-check",
|
||||
LOCATION_CHANGED: "map-pin",
|
||||
HOST_NO_SHOW_UPDATED: "ban",
|
||||
ATTENDEE_NO_SHOW_UPDATED: "ban",
|
||||
NO_SHOW_UPDATED: "ban",
|
||||
} as const;
|
||||
|
||||
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
|
||||
@@ -182,6 +181,32 @@ function JsonViewer({ data }: JsonViewerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DisplayFieldValueProps {
|
||||
field: {
|
||||
valueKey?: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (field.values) {
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
{field.values.map((v, i) => (
|
||||
<span className="p-0.5" key={i}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
const { t } = useLocale();
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -279,10 +304,10 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">
|
||||
{t(field.labelKey)}
|
||||
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
||||
<span className="font-medium">
|
||||
<DisplayFieldValue field={field} />
|
||||
</span>
|
||||
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
@@ -342,15 +367,23 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const doesMatchDisplayFields = (): boolean => {
|
||||
return log.displayFields?.some((field) => {
|
||||
return field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}) ?? false;
|
||||
return (
|
||||
log.displayFields?.some((field) => {
|
||||
return (
|
||||
field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}) ?? false
|
||||
);
|
||||
};
|
||||
|
||||
const doesMatchActionDisplayTitle = (): boolean => {
|
||||
return log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) => param?.toString().toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
return (
|
||||
log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) =>
|
||||
param?.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const matchesSearch =
|
||||
|
||||
@@ -81,7 +81,7 @@ const mockEvent = {
|
||||
isPending: false,
|
||||
};
|
||||
|
||||
vi.mock("@calcom/features/calendars/NoAvailabilityDialog", () => ({
|
||||
vi.mock("@calcom/features/calendars/components/NoAvailabilityDialog", () => ({
|
||||
default: () => {
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -93,7 +93,7 @@ const BookerComponent = ({
|
||||
roundRobinHideOrgAndTeam,
|
||||
hideOrgTeamAvatar,
|
||||
showNoAvailabilityDialog,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
}: BookerProps & WrappedBookerProps): JSX.Element | null => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
const [bookerState, setBookerState] = useBookerStoreContext(
|
||||
@@ -180,13 +180,7 @@ const BookerComponent = ({
|
||||
isVerificationCodeSending,
|
||||
} = verifyEmail;
|
||||
|
||||
const {
|
||||
overlayBusyDates,
|
||||
isOverlayCalendarEnabled,
|
||||
connectedCalendars,
|
||||
loadingConnectedCalendar,
|
||||
onToggleCalendar,
|
||||
} = calendars;
|
||||
const { overlayBusyDates, isOverlayCalendarEnabled, connectedCalendars, onToggleCalendar } = calendars;
|
||||
|
||||
const scrolledToTimeslotsOnce = useRef(false);
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
@@ -276,7 +270,11 @@ const BookerComponent = ({
|
||||
renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail();
|
||||
|
||||
const EventBooker = useMemo(() => {
|
||||
return bookerState === "booking" ? (
|
||||
if (bookerState !== "booking") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BookEventForm
|
||||
key={key}
|
||||
timeslot={selectedTimeslot}
|
||||
@@ -312,22 +310,18 @@ const BookerComponent = ({
|
||||
backButton: customClassNames?.confirmStep?.backButton,
|
||||
}}
|
||||
isPlatform={isPlatform}>
|
||||
<>
|
||||
{!isPlatform && (
|
||||
<RedirectToInstantMeetingModal
|
||||
expiryTime={expiryTime}
|
||||
bookingId={parseInt(getQueryParam("bookingId") || "0")}
|
||||
instantVideoMeetingUrl={instantVideoMeetingUrl}
|
||||
onGoBack={() => {
|
||||
onGoBackInstantMeeting();
|
||||
}}
|
||||
orgName={event.data?.entity?.name}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{!isPlatform && (
|
||||
<RedirectToInstantMeetingModal
|
||||
expiryTime={expiryTime}
|
||||
bookingId={parseInt(getQueryParam("bookingId") || "0")}
|
||||
instantVideoMeetingUrl={instantVideoMeetingUrl}
|
||||
onGoBack={() => {
|
||||
onGoBackInstantMeeting();
|
||||
}}
|
||||
orgName={event.data?.entity?.name}
|
||||
/>
|
||||
)}
|
||||
</BookEventForm>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}, [
|
||||
bookerFormErrorRef,
|
||||
@@ -418,27 +412,23 @@ const BookerComponent = ({
|
||||
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
|
||||
isMobile={isMobile}
|
||||
nextSlots={nextSlots}
|
||||
renderOverlay={() =>
|
||||
isEmbed ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<OverlayCalendar
|
||||
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
|
||||
connectedCalendars={connectedCalendars}
|
||||
loadingConnectedCalendar={loadingConnectedCalendar}
|
||||
overlayBusyDates={overlayBusyDates}
|
||||
onToggleCalendar={onToggleCalendar}
|
||||
hasSession={hasSession}
|
||||
handleClickContinue={onClickOverlayContinue}
|
||||
handleSwitchStateChange={onOverlaySwitchStateChange}
|
||||
handleClickNoCalendar={() => {
|
||||
onOverlayClickNoCalendar();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
renderOverlay={() => {
|
||||
if (isEmbed) return null;
|
||||
return (
|
||||
<OverlayCalendar
|
||||
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
|
||||
connectedCalendars={connectedCalendars}
|
||||
overlayBusyDates={overlayBusyDates}
|
||||
onToggleCalendar={onToggleCalendar}
|
||||
hasSession={hasSession}
|
||||
handleClickContinue={onClickOverlayContinue}
|
||||
handleSwitchStateChange={onOverlaySwitchStateChange}
|
||||
handleClickNoCalendar={() => {
|
||||
onOverlayClickNoCalendar();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</BookerSection>
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useCallback, useEffect } from "react";
|
||||
import React from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks";
|
||||
import type { BookerProps } from "@calcom/features/bookings/Booker";
|
||||
import {
|
||||
BookerStoreProvider,
|
||||
useInitializeBookerStoreContext,
|
||||
useBookerStoreContext,
|
||||
useInitializeBookerStoreContext,
|
||||
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
||||
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
||||
@@ -22,27 +16,23 @@ import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useS
|
||||
import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
|
||||
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
|
||||
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import {
|
||||
useEvent,
|
||||
useScheduleForEvent,
|
||||
} from "@calcom/features/bookings/Booker/utils/event";
|
||||
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
|
||||
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
|
||||
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import {
|
||||
DEFAULT_LIGHT_BRAND_COLOR,
|
||||
DEFAULT_DARK_BRAND_COLOR,
|
||||
WEBAPP_URL,
|
||||
} from "@calcom/lib/constants";
|
||||
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { Booker as BookerComponent } from "./Booker";
|
||||
|
||||
export type BookerWebWrapperAtomProps = BookerProps & {
|
||||
eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
|
||||
};
|
||||
|
||||
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Element => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -64,17 +54,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
const isRedirect = searchParams?.get("redirected") === "true" || false;
|
||||
const fromUserNameRedirected = searchParams?.get("username") || "";
|
||||
const rescheduleUid =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("rescheduleUid")
|
||||
: null;
|
||||
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
|
||||
const rescheduledBy =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("rescheduledBy")
|
||||
: null;
|
||||
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduledBy") : null;
|
||||
const bookingUid =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("bookingUid")
|
||||
: null;
|
||||
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null;
|
||||
const timezone = searchParams?.get("cal.tz") || null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,10 +86,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
timezone,
|
||||
});
|
||||
|
||||
const [dayCount] = useBookerStoreContext(
|
||||
(state) => [state.dayCount, state.setDayCount],
|
||||
shallow
|
||||
);
|
||||
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
|
||||
|
||||
const { data: session } = useSession();
|
||||
const routerQuery = useRouterQuery();
|
||||
@@ -117,8 +98,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
.reduce(
|
||||
(metadata, key) => ({
|
||||
...metadata,
|
||||
[key.substring("metadata[".length, key.length - 1)]:
|
||||
searchParams?.get(key),
|
||||
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
@@ -126,11 +106,8 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
return {
|
||||
name:
|
||||
searchParams?.get("name") ||
|
||||
(firstNameQueryParam
|
||||
? `${firstNameQueryParam} ${lastNameQueryParam}`
|
||||
: null),
|
||||
guests:
|
||||
(searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
|
||||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
|
||||
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
|
||||
};
|
||||
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
|
||||
|
||||
@@ -147,13 +124,10 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
const verifyEmail = useVerifyEmail({
|
||||
email: bookerForm.formEmail,
|
||||
name: bookerForm.formName,
|
||||
requiresBookerEmailVerification:
|
||||
event?.data?.requiresBookerEmailVerification,
|
||||
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
|
||||
onVerifyEmail: bookerForm.beforeVerifyEmail,
|
||||
});
|
||||
const slots = useSlots(
|
||||
event?.data ? { id: event.data.id, length: event.data.length } : null
|
||||
);
|
||||
const slots = useSlots(event?.data ? { id: event.data.id, length: event.data.length } : null);
|
||||
|
||||
const isEmbed = useIsEmbed();
|
||||
|
||||
@@ -217,8 +191,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
|
||||
);
|
||||
useBrandColors({
|
||||
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor:
|
||||
event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
|
||||
darkBrandColor: event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
|
||||
theme: event.data?.profile.theme,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import React from "react";
|
||||
import { vi, afterEach } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
|
||||
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/components/DatePicker";
|
||||
|
||||
import { render } from "@calcom/features/bookings/Booker/__tests__/test-utils";
|
||||
import { DatePicker } from "./DatePicker";
|
||||
|
||||
vi.mock("@calcom/features/calendars/DatePicker", () => {
|
||||
vi.mock("@calcom/features/calendars/components/DatePicker", () => {
|
||||
return {
|
||||
DatePicker: vi.fn(() => <div data-testid="mock-date-picker" />),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
import type { DatePickerClassNames } from "@calcom/features/bookings/Booker/types";
|
||||
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
|
||||
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/components/DatePicker";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
|
||||
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
|
||||
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
|
||||
import { useOverlayCalendar } from "@calcom/features/bookings/Booker/components/hooks/useOverlayCalendar";
|
||||
import type { WrappedBookerPropsMain } from "@calcom/features/bookings/Booker/types";
|
||||
import { useEffect } from "react";
|
||||
import { OverlayCalendarContinueModal } from "./OverlayCalendarContinueModal";
|
||||
import { OverlayCalendarSettingsModal } from "./OverlayCalendarSettingsModal";
|
||||
import { OverlayCalendarSwitch } from "./OverlayCalendarSwitch";
|
||||
|
||||
type OverlayCalendarProps = Pick<
|
||||
UseCalendarsReturnType,
|
||||
| "connectedCalendars"
|
||||
| "overlayBusyDates"
|
||||
| "onToggleCalendar"
|
||||
| "loadingConnectedCalendar"
|
||||
| "isOverlayCalendarEnabled"
|
||||
WrappedBookerPropsMain["calendars"],
|
||||
"connectedCalendars" | "overlayBusyDates" | "onToggleCalendar" | "isOverlayCalendarEnabled"
|
||||
> & {
|
||||
handleClickNoCalendar: () => void;
|
||||
hasSession: boolean;
|
||||
@@ -27,12 +21,11 @@ export const OverlayCalendar = ({
|
||||
overlayBusyDates,
|
||||
onToggleCalendar,
|
||||
isOverlayCalendarEnabled,
|
||||
loadingConnectedCalendar,
|
||||
handleClickNoCalendar,
|
||||
handleSwitchStateChange,
|
||||
handleClickContinue,
|
||||
hasSession,
|
||||
}: OverlayCalendarProps) => {
|
||||
}: OverlayCalendarProps): JSX.Element | null => {
|
||||
const isPlatform = useIsPlatform();
|
||||
const {
|
||||
handleCloseContinueModal,
|
||||
@@ -70,10 +63,8 @@ export const OverlayCalendar = ({
|
||||
/>
|
||||
)}
|
||||
<OverlayCalendarSettingsModal
|
||||
connectedCalendars={connectedCalendars}
|
||||
open={isOpenOverlaySettingsModal}
|
||||
onClose={handleCloseSettingsModal}
|
||||
isLoading={loadingConnectedCalendar}
|
||||
onToggleConnectedCalendar={handleToggleConnectedCalendar}
|
||||
onClickNoCalendar={() => {
|
||||
handleClickNoCalendar();
|
||||
|
||||
+93
-109
@@ -1,34 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { DialogContent, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { DialogClose, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list";
|
||||
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
|
||||
|
||||
interface IOverlayCalendarSettingsModalProps {
|
||||
type IOverlayCalendarSettingsModalProps = {
|
||||
open?: boolean;
|
||||
onClose?: (state: boolean) => void;
|
||||
onClickNoCalendar?: () => void;
|
||||
isLoading: boolean;
|
||||
connectedCalendars: UseCalendarsReturnType["connectedCalendars"];
|
||||
onToggleConnectedCalendar: (externalCalendarId: string, credentialId: number) => void;
|
||||
checkIsCalendarToggled: (externalCalendarId: string, credentialId: number) => boolean;
|
||||
}
|
||||
};
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
const SkeletonLoader = (): JSX.Element => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle mt-3 stack-y-4 rounded-xl border px-4 py-4 ">
|
||||
<div className="stack-y-4 mt-3 rounded-xl border border-subtle px-4 py-4">
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
@@ -39,109 +35,97 @@ const SkeletonLoader = () => {
|
||||
};
|
||||
|
||||
export function OverlayCalendarSettingsModal({
|
||||
connectedCalendars,
|
||||
isLoading,
|
||||
open,
|
||||
onClose,
|
||||
onClickNoCalendar,
|
||||
onToggleConnectedCalendar,
|
||||
checkIsCalendarToggled,
|
||||
}: IOverlayCalendarSettingsModalProps) {
|
||||
}: IOverlayCalendarSettingsModalProps): JSX.Element {
|
||||
const { t } = useLocale();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { data, isPending: isLoading } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
|
||||
enabled: !!open,
|
||||
});
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (isLoading) {
|
||||
content = <SkeletonLoader />;
|
||||
} else if (data?.connectedCalendars.length === 0) {
|
||||
content = (
|
||||
<EmptyScreen
|
||||
Icon="calendar"
|
||||
headline={t("no_calendar_installed")}
|
||||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={onClickNoCalendar}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = data?.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && !item.calendars && <Alert severity="error" title={item.error.message} />}
|
||||
{item?.error === undefined && item.calendars && (
|
||||
<ListItem className="flex-col rounded-md">
|
||||
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
|
||||
{item.integration.logo && (
|
||||
<Image
|
||||
className={classNames(
|
||||
"h-10 w-10",
|
||||
item.integration.logo.includes("-dark") && "dark:invert"
|
||||
)}
|
||||
width={40}
|
||||
height={40}
|
||||
src={isPlatform ? `https://app.cal.com${item.integration.logo}` : item.integration.logo}
|
||||
alt={`${item.integration.title} logo`}
|
||||
/>
|
||||
)}
|
||||
<div className="grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
|
||||
<Link href={`/apps/${item.integration.slug}`}>
|
||||
{item.integration.name || item.integration.title}
|
||||
</Link>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{item.primary?.email}</ListItemText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle w-full border-t pt-4">
|
||||
<ul className="stack-y-4">
|
||||
{item.calendars.map((cal, index) => {
|
||||
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
|
||||
return (
|
||||
<li className="flex gap-3" key={id}>
|
||||
<Switch
|
||||
id={id}
|
||||
label={cal.name}
|
||||
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
|
||||
onCheckedChange={() => {
|
||||
onToggleConnectedCalendar(cal.externalId, item.credentialId);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</ListItem>
|
||||
)}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
type="creation"
|
||||
title="Calendar Settings"
|
||||
className="pb-4"
|
||||
description={t("view_overlay_calendar_events")}>
|
||||
<div className="no-scrollbar max-h-full overflow-y-scroll ">
|
||||
{isLoading ? (
|
||||
<SkeletonLoader />
|
||||
) : (
|
||||
<>
|
||||
{connectedCalendars.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon="calendar"
|
||||
headline={t("no_calendar_installed")}
|
||||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={onClickNoCalendar}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && !item.calendars && (
|
||||
<Alert severity="error" title={item.error.message} />
|
||||
)}
|
||||
{item?.error === undefined && item.calendars && (
|
||||
<ListItem className="flex-col rounded-md">
|
||||
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
item.integration.logo && (
|
||||
<img
|
||||
className={classNames(
|
||||
"h-10 w-10",
|
||||
item.integration.logo.includes("-dark") && "dark:invert"
|
||||
)}
|
||||
src={
|
||||
isPlatform
|
||||
? `https://app.cal.com${item.integration.logo}`
|
||||
: item.integration.logo
|
||||
}
|
||||
alt={`${item.integration.title} logo`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
|
||||
<Link href={`/apps/${item.integration.slug}`}>
|
||||
{item.integration.name || item.integration.title}
|
||||
</Link>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{item.primary.email}</ListItemText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle w-full border-t pt-4">
|
||||
<ul className="stack-y-4">
|
||||
{item.calendars.map((cal, index) => {
|
||||
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
|
||||
return (
|
||||
<li className="flex gap-3" key={id}>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
|
||||
onCheckedChange={() => {
|
||||
onToggleConnectedCalendar(cal.externalId, item.credentialId);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={id}>{cal.name}</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</ListItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 self-end">
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
type="creation"
|
||||
title={t("calendar_settings")}
|
||||
className="pb-4"
|
||||
description={t("view_overlay_calendar_events")}>
|
||||
<div className="no-scrollbar max-h-full overflow-y-scroll">{content}</div>
|
||||
<div className="mt-4 flex gap-2 self-end">
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { TopBanner } from "@calcom/ui/components/top-banner";
|
||||
|
||||
export type DueInvoiceBannerProps = {
|
||||
data: RouterOutputs["viewer"]["me"]["getUserTopBanners"]["dueInvoiceBanner"];
|
||||
};
|
||||
|
||||
export function DueInvoiceBanner({ data }: DueInvoiceBannerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
// Get the first proration to display (prioritize blocking ones)
|
||||
const blockingProrations = data.filter((d) => d.isBlocking);
|
||||
const displayProration = blockingProrations[0] ?? data[0];
|
||||
const hasBlocking = blockingProrations.length > 0;
|
||||
|
||||
// Use Stripe invoice URL if available, otherwise fall back to billing settings
|
||||
const paymentUrl =
|
||||
displayProration.invoiceUrl ?? `/settings/teams/${displayProration.teamId}/billing`;
|
||||
|
||||
return (
|
||||
<TopBanner
|
||||
variant={hasBlocking ? "error" : "warning"}
|
||||
text={t(hasBlocking ? "invoice_overdue_blocking" : "invoice_due", {
|
||||
teamName: displayProration.teamName,
|
||||
})}
|
||||
actions={
|
||||
<a
|
||||
href={paymentUrl}
|
||||
target={displayProration.invoiceUrl ? "_blank" : undefined}
|
||||
rel={displayProration.invoiceUrl ? "noopener noreferrer" : undefined}
|
||||
className="border-b border-b-black hover:opacity-80">
|
||||
{t("pay_now")}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
useEvent,
|
||||
useScheduleForEvent,
|
||||
} from "@calcom/features/bookings/Booker/utils/event";
|
||||
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||
import DatePicker from "@calcom/features/calendars/components/DatePicker";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { TimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
|
||||
import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
|
||||
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
|
||||
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { getOptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { getOptInFeatureConfig, shouldDisplayFeatureAt } from "@calcom/features/feature-opt-in/config";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
@@ -122,6 +122,8 @@ function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
|
||||
if (isDismissed) return false;
|
||||
if (isOptedIn) return false;
|
||||
if (!featureConfig) return false;
|
||||
// Only show banner if the feature is configured to be displayed as a banner
|
||||
if (!shouldDisplayFeatureAt(featureConfig, "banner")) return false;
|
||||
if (eligibilityQuery.isLoading) return false;
|
||||
if (!eligibilityQuery.data) return false;
|
||||
return eligibilityQuery.data.status === "can_opt_in";
|
||||
|
||||
@@ -55,7 +55,7 @@ function createTeamBlockedWarningFn(
|
||||
return (feature: NormalizedFeature): string | null => {
|
||||
const blockingState = blockingStateMap.get(feature.slug);
|
||||
if (blockingState?.orgState === "disabled") {
|
||||
return t("feature_blocked_by_org_warning");
|
||||
return t("feature_org_disabled");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -41,22 +41,18 @@ function normalizeUserFeatures(data: UserFeatureData[] | undefined): NormalizedF
|
||||
}));
|
||||
}
|
||||
|
||||
const WARNING_REASONS: Set<string> = new Set([
|
||||
"feature_org_disabled",
|
||||
"feature_all_teams_disabled",
|
||||
"feature_any_team_disabled",
|
||||
"feature_no_explicit_enablement",
|
||||
"feature_user_only_not_allowed",
|
||||
]);
|
||||
|
||||
function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature) => string | null {
|
||||
return (feature: NormalizedFeature): string | null => {
|
||||
if (!feature.effectiveReason) return null;
|
||||
switch (feature.effectiveReason) {
|
||||
case "feature_org_disabled":
|
||||
return t("feature_blocked_by_org_warning");
|
||||
case "feature_all_teams_disabled":
|
||||
case "feature_any_team_disabled":
|
||||
return t("feature_blocked_by_team_warning");
|
||||
case "feature_no_explicit_enablement":
|
||||
return t("feature_no_explicit_enablement_warning");
|
||||
case "feature_user_only_not_allowed":
|
||||
return t("feature_user_only_not_allowed_warning");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (!feature.effectiveReason || !WARNING_REASONS.has(feature.effectiveReason)) return null;
|
||||
return t(feature.effectiveReason);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
|
||||
import type { Session } from "next-auth";
|
||||
import { CrudAction, CustomAction, Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
const getCachedAttributes = unstable_cache(
|
||||
async (orgId: number) => {
|
||||
@@ -60,6 +58,7 @@ export async function getOrgMembersPageData(session: Session) {
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.Impersonate,
|
||||
CustomAction.PasswordReset,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.ListMembers]: {
|
||||
@@ -80,6 +79,9 @@ export async function getOrgMembersPageData(session: Session) {
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.PasswordReset]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
getSpecificPermissions({
|
||||
@@ -107,6 +109,7 @@ export async function getOrgMembersPageData(session: Session) {
|
||||
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: orgPermissions[CustomAction.Remove],
|
||||
canImpersonate: orgPermissions[CustomAction.Impersonate],
|
||||
canResetPassword: orgPermissions[CustomAction.PasswordReset],
|
||||
canViewAttributes: attributesPermissions[CrudAction.Read],
|
||||
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ColorPicker, Label } from "@calcom/ui/components/form";
|
||||
import { BannerUploader, ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
@@ -25,49 +26,24 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
|
||||
const { isMigrationFlow, hasTeams } = useMigrationFlow();
|
||||
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [_bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setLogoPreview(organizationBrand.logo);
|
||||
setBannerPreview(organizationBrand.banner);
|
||||
setBrandColor(organizationBrand.color);
|
||||
}, [organizationBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
setLogoFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setLogoPreview(base64);
|
||||
setOrganizationBrand({ logo: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
setOrganizationBrand({ logo: null });
|
||||
}
|
||||
const handleLogoChange = (newLogo: string) => {
|
||||
setLogoPreview(newLogo);
|
||||
setOrganizationBrand({ logo: newLogo });
|
||||
};
|
||||
|
||||
const handleBannerChange = (file: File | null) => {
|
||||
setBannerFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setBannerPreview(base64);
|
||||
setOrganizationBrand({ banner: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setBannerPreview(null);
|
||||
setOrganizationBrand({ banner: null });
|
||||
}
|
||||
const handleBannerChange = (newBanner: string) => {
|
||||
setBannerPreview(newBanner);
|
||||
setOrganizationBrand({ banner: newBanner });
|
||||
};
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
@@ -154,21 +130,28 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
<div className="flex gap-2">
|
||||
<BannerUploader
|
||||
id="org-banner-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={handleBannerChange}
|
||||
imageSrc={bannerPreview || undefined}
|
||||
target="banner"
|
||||
triggerButtonColor="secondary"
|
||||
height={500}
|
||||
width={1500}
|
||||
/>
|
||||
{bannerPreview && (
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBannerPreview(null);
|
||||
setOrganizationBrand({ banner: null });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
@@ -188,21 +171,26 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ImageUploader
|
||||
id="org-logo-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={logoPreview || undefined}
|
||||
target="logo"
|
||||
triggerButtonColor="secondary"
|
||||
buttonSize="sm"
|
||||
/>
|
||||
{logoPreview && (
|
||||
<Button
|
||||
color="secondary"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
onClick={() => {
|
||||
setLogoPreview(null);
|
||||
setOrganizationBrand({ logo: null });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import type { Control, FieldValues } from "react-hook-form";
|
||||
|
||||
import {
|
||||
ScheduleComponent,
|
||||
type FieldPathByValue,
|
||||
type ScheduleLabelsType,
|
||||
} from "@calcom/features/schedules/components/ScheduleComponent";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
const Schedule = <
|
||||
TFieldValues extends FieldValues,
|
||||
TPath extends FieldPathByValue<TFieldValues, TimeRange[][]>
|
||||
>(props: {
|
||||
name: TPath;
|
||||
control: Control<TFieldValues>;
|
||||
weekStart?: number;
|
||||
disabled?: boolean;
|
||||
labels?: ScheduleLabelsType;
|
||||
userTimeFormat?: number | null;
|
||||
}) => {
|
||||
const query = useMeQuery();
|
||||
const { timeFormat } = query.data || { timeFormat: null };
|
||||
|
||||
return <ScheduleComponent userTimeFormat={timeFormat} {...props} />;
|
||||
};
|
||||
|
||||
export default Schedule;
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DueInvoiceBanner,
|
||||
type DueInvoiceBannerProps,
|
||||
} from "@calcom/web/modules/ee/billing/components/DueInvoiceBanner";
|
||||
import ImpersonatingBanner, {
|
||||
type ImpersonatingBannerProps,
|
||||
} from "@calcom/web/modules/ee/impersonation/components/ImpersonatingBanner";
|
||||
@@ -31,6 +35,7 @@ type BannerTypeProps = {
|
||||
impersonationBanner: ImpersonatingBannerProps;
|
||||
calendarCredentialBanner: CalendarCredentialBannerProps;
|
||||
invalidAppCredentialBanners: InvalidAppCredentialBannersProps;
|
||||
dueInvoiceBanner: DueInvoiceBannerProps;
|
||||
};
|
||||
|
||||
type BannerType = keyof BannerTypeProps;
|
||||
@@ -51,6 +56,7 @@ export const BannerComponent: BannerComponent = {
|
||||
invalidAppCredentialBanners: (props: InvalidAppCredentialBannersProps) => (
|
||||
<InvalidAppCredentialBanners {...props} />
|
||||
),
|
||||
dueInvoiceBanner: (props: DueInvoiceBannerProps) => <DueInvoiceBanner {...props} />,
|
||||
};
|
||||
|
||||
interface BannerContainerProps {
|
||||
@@ -82,6 +88,9 @@ export const BannerContainer: React.FC<BannerContainerProps> = ({ banners }) =>
|
||||
} else if (key === "invalidAppCredentialBanners") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
} else if (key === "dueInvoiceBanner") {
|
||||
const Banner = BannerComponent[key];
|
||||
return <Banner data={banners[key]} key={key} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { TimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
|
||||
import DateOverrideInputDialog from "@calcom/features/schedules/components/DateOverrideInputDialog";
|
||||
import DateOverrideList from "@calcom/features/schedules/components/DateOverrideList";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Schedule from "@calcom/web/modules/schedules/components/Schedule";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
@@ -95,10 +95,7 @@ const initalColumnVisibility = {
|
||||
actions: true,
|
||||
};
|
||||
|
||||
function reducer(
|
||||
state: UserTableState,
|
||||
action: UserTableAction
|
||||
): UserTableState {
|
||||
function reducer(state: UserTableState, action: UserTableAction): UserTableState {
|
||||
switch (action.type) {
|
||||
case "SET_CHANGE_MEMBER_ROLE_ID":
|
||||
return { ...state, changeMemberRole: action.payload };
|
||||
@@ -146,11 +143,7 @@ function UserListTable(props: UserListTableProps): JSX.Element | null {
|
||||
const pathname = usePathname();
|
||||
if (!pathname) return null;
|
||||
return (
|
||||
<DataTableProvider
|
||||
tableIdentifier={pathname}
|
||||
useSegments={useSegments}
|
||||
defaultPageSize={25}
|
||||
>
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} defaultPageSize={25}>
|
||||
<UserListTableContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
@@ -203,6 +196,7 @@ function UserListTableContent({
|
||||
canRemove: permissions?.canRemove ?? adminOrOwner,
|
||||
canResendInvitation: permissions?.canInvite ?? adminOrOwner,
|
||||
canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
|
||||
canResetPassword: permissions?.canResetPassword ?? adminOrOwner,
|
||||
};
|
||||
const generateAttributeColumns = (): ColumnDef<UserTableUser>[] => {
|
||||
if (!attributes?.length) {
|
||||
@@ -326,14 +320,12 @@ function UserListTableContent({
|
||||
<div className="">
|
||||
<div
|
||||
data-testid={`member-${username}-username`}
|
||||
className="text-emphasis text-sm font-medium leading-none"
|
||||
>
|
||||
className="text-emphasis text-sm font-medium leading-none">
|
||||
{displayName}
|
||||
</div>
|
||||
<div
|
||||
data-testid={`member-${username}-email`}
|
||||
className="text-subtle mt-1 text-sm leading-none"
|
||||
>
|
||||
className="text-subtle mt-1 text-sm leading-none">
|
||||
{email}
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,8 +354,7 @@ function UserListTableContent({
|
||||
variant={roleVariant}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue([role]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{roleName}
|
||||
</Badge>
|
||||
);
|
||||
@@ -390,8 +381,7 @@ function UserListTableContent({
|
||||
data-testid={`email-${email.replace("@", "")}-pending`}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(["PENDING"]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{t("pending")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -502,8 +492,7 @@ function UserListTableContent({
|
||||
|
||||
const permissionsForUser = {
|
||||
canEdit:
|
||||
((permissionsRaw.canEdit ?? false) ||
|
||||
(permissions?.canEditAttributesForUser ?? false)) &&
|
||||
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
|
||||
user.accepted &&
|
||||
!isSelf,
|
||||
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
|
||||
@@ -514,8 +503,8 @@ function UserListTableContent({
|
||||
!!org?.canAdminImpersonate &&
|
||||
(permissionsRaw.canImpersonate ?? false),
|
||||
canLeave: user.accepted && isSelf,
|
||||
canResendInvitation:
|
||||
(permissionsRaw.canResendInvitation ?? false) && !user.accepted,
|
||||
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
|
||||
canResetPassword: (permissionsRaw.canResetPassword ?? false) && user.accepted && !isSelf,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -573,9 +562,7 @@ function UserListTableContent({
|
||||
}))
|
||||
);
|
||||
default: {
|
||||
const attribute = facetedTeamValues.attributes.find(
|
||||
(attr) => attr.id === columnId
|
||||
);
|
||||
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
|
||||
if (attribute) {
|
||||
return convertFacetedValuesToMap(
|
||||
attribute?.options.map(({ value }) => ({
|
||||
@@ -635,19 +622,12 @@ function UserListTableContent({
|
||||
}
|
||||
|
||||
const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? [];
|
||||
const csvRaw = generateCsvRawForMembersTable(
|
||||
headers,
|
||||
allRows,
|
||||
ATTRIBUTE_IDS,
|
||||
domain
|
||||
);
|
||||
const csvRaw = generateCsvRawForMembersTable(headers, allRows, ATTRIBUTE_IDS, domain);
|
||||
if (!csvRaw) {
|
||||
throw new Error("Generating CSV file failed.");
|
||||
}
|
||||
|
||||
const filename = `${org.name}_${
|
||||
new Date().toISOString().split("T")[0]
|
||||
}.csv`;
|
||||
const filename = `${org.name}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
downloadAsCsv(csvRaw, filename);
|
||||
} catch (error) {
|
||||
showToast(`Error: ${error}`, "error");
|
||||
@@ -677,8 +657,7 @@ function UserListTableContent({
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</>
|
||||
}
|
||||
>
|
||||
}>
|
||||
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
|
||||
<DataTableSelectionBar.Root className="bottom-[7.3rem]! md:bottom-32!">
|
||||
<DynamicLink table={table} domain={domain} />
|
||||
@@ -691,23 +670,17 @@ function UserListTableContent({
|
||||
</p>
|
||||
{!isPlatformUser && (
|
||||
<>
|
||||
{permissions?.canChangeMemberRole && (
|
||||
<TeamListBulkAction table={table} />
|
||||
)}
|
||||
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
|
||||
{numberOfSelectedRows >= 2 && (
|
||||
<DataTableSelectionBar.Button
|
||||
color="secondary"
|
||||
onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)}
|
||||
icon="handshake"
|
||||
>
|
||||
icon="handshake">
|
||||
{t("group_meeting")}
|
||||
</DataTableSelectionBar.Button>
|
||||
)}
|
||||
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
|
||||
<MassAssignAttributesBulkAction
|
||||
table={table}
|
||||
filters={columnFilters}
|
||||
/>
|
||||
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
|
||||
)}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
<EventTypesList table={table} orgTeams={teams} />
|
||||
@@ -716,9 +689,7 @@ function UserListTableContent({
|
||||
)}
|
||||
{(permissions?.canRemove ?? adminOrOwner) && (
|
||||
<DeleteBulkUsers
|
||||
users={table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original)}
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -726,18 +697,10 @@ function UserListTableContent({
|
||||
)}
|
||||
</DataTableWrapper>
|
||||
|
||||
{state.deleteMember.showModal && (
|
||||
<DeleteMemberModal state={state} dispatch={dispatch} />
|
||||
)}
|
||||
{state.inviteMember.showModal && (
|
||||
<InviteMemberModal dispatch={dispatch} />
|
||||
)}
|
||||
{state.impersonateMember.showModal && (
|
||||
<ImpersonationMemberModal dispatch={dispatch} state={state} />
|
||||
)}
|
||||
{state.changeMemberRole.showModal && (
|
||||
<ChangeUserRoleModal dispatch={dispatch} state={state} />
|
||||
)}
|
||||
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
|
||||
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
|
||||
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
|
||||
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
|
||||
{state.editSheet.showModal && (
|
||||
<EditUserSheet
|
||||
dispatch={dispatch}
|
||||
@@ -757,8 +720,7 @@ function UserListTableContent({
|
||||
StartIcon="file-down"
|
||||
loading={isDownloading}
|
||||
onClick={() => handleDownload()}
|
||||
data-testid="export-members-button"
|
||||
>
|
||||
data-testid="export-members-button">
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
{(permissions?.canInvite ?? adminOrOwner) && (
|
||||
@@ -775,8 +737,7 @@ function UserListTableContent({
|
||||
});
|
||||
posthog.capture("add_organization_member_clicked");
|
||||
}}
|
||||
data-testid="new-organization-member-button"
|
||||
>
|
||||
data-testid="new-organization-member-button">
|
||||
{t("add")}
|
||||
</DataTableToolbar.CTA>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -7,17 +5,17 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import type { UserTableUser, UserTableAction } from "./types";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { UserTableAction, UserTableUser } from "./types";
|
||||
|
||||
export function TableActions({
|
||||
user,
|
||||
@@ -33,6 +31,7 @@ export function TableActions({
|
||||
canRemove: boolean;
|
||||
canImpersonate: boolean;
|
||||
canResendInvitation: boolean;
|
||||
canResetPassword: boolean;
|
||||
};
|
||||
}) {
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -45,6 +44,14 @@ export function TableActions({
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
const sendPasswordResetMutation = trpc.viewer.organizations.sendPasswordReset.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("password_reset_email_sent"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const usersProfileUrl = `${domain}/${user.username}`;
|
||||
|
||||
@@ -96,26 +103,37 @@ export function TableActions({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{permissionsForUser.canImpersonate && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_IMPERSONATE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="lock">
|
||||
{t("impersonate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_IMPERSONATE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="lock">
|
||||
{t("impersonate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{permissionsForUser.canResetPassword && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sendPasswordResetMutation.mutate({ userId: user.id });
|
||||
}}
|
||||
StartIcon="key">
|
||||
{t("reset_password")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(permissionsForUser.canImpersonate || permissionsForUser.canResetPassword) &&
|
||||
permissionsForUser.canRemove && <DropdownMenuSeparator />}
|
||||
{permissionsForUser.canRemove && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
@@ -170,24 +188,34 @@ export function TableActions({
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{permissionsForUser.canEdit && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{permissionsForUser.canResetPassword && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sendPasswordResetMutation.mutate({ userId: user.id });
|
||||
}}
|
||||
StartIcon="key">
|
||||
{t("reset_password")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{permissionsForUser.canRemove && (
|
||||
<DropdownMenuItem>
|
||||
|
||||
@@ -3,15 +3,15 @@ import { config as dotenvConfig } from "dotenv";
|
||||
import type { NextConfig } from "next";
|
||||
import type { RouteHas } from "next/dist/lib/load-custom-routes";
|
||||
import { withAxiom } from "next-axiom";
|
||||
|
||||
import i18nConfig from "./next-i18next.config";
|
||||
import packageJson from "./package.json";
|
||||
import {
|
||||
nextJsOrgRewriteConfig,
|
||||
orgUserRoutePath,
|
||||
orgUserTypeRoutePath,
|
||||
orgUserTypeEmbedRoutePath,
|
||||
orgUserTypeRoutePath,
|
||||
} from "./pagesAndRewritePaths";
|
||||
import { TRIGGER_VERSION } from "./trigger.version"; // adjust path as needed
|
||||
|
||||
dotenvConfig({ path: "../../.env" });
|
||||
|
||||
@@ -58,6 +58,10 @@ const env = process.env as Record<string, string | undefined>;
|
||||
|
||||
env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
||||
|
||||
if (process.env.NODE_ENV === "production" || process.env.CALCOM_ENV === "production") {
|
||||
env.TRIGGER_VERSION = TRIGGER_VERSION;
|
||||
}
|
||||
|
||||
if (process.env.VERCEL_URL && !process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||
env.NEXT_PUBLIC_WEBAPP_URL = `https://${process.env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "6.1.7",
|
||||
"version": "6.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OPT_IN_FEATURES } from "@calcom/features/feature-opt-in/config";
|
||||
import { getOptInFeatureConfig, shouldDisplayFeatureAt } from "@calcom/features/feature-opt-in/config";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
@@ -10,7 +10,8 @@ test.describe("Feature Opt-In Banner", () => {
|
||||
users,
|
||||
prisma,
|
||||
}) => {
|
||||
if (!OPT_IN_FEATURES.some((item) => item.slug === "bookings-v3")) {
|
||||
const featureConfig = getOptInFeatureConfig("bookings-v3");
|
||||
if (!featureConfig || !shouldDisplayFeatureAt(featureConfig, "banner")) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"info": "Info",
|
||||
"welcome": "Welcome",
|
||||
"welcome_back": "Welcome back",
|
||||
"welcome_back_sign_in": "Welcome back! Sign in to continue",
|
||||
"welcome_to_calcom": "Welcome to {{appName}}",
|
||||
"personal_welcome_description": "You're all set! Start scheduling meetings and managing your calendar.",
|
||||
"unlimited_calendars": "Unlimited calendars",
|
||||
@@ -1888,6 +1889,9 @@
|
||||
"calendars": "Calendars",
|
||||
"2fa_auth": "Two factor authentication",
|
||||
"invoices": "Invoices",
|
||||
"invoice_due": "You have an unpaid invoice for \"{{teamName}}\". Please pay to avoid service interruption.",
|
||||
"invoice_overdue_blocking": "Your invoice for \"{{teamName}}\" is overdue. Member invitations are blocked until payment.",
|
||||
"invitations_blocked_unpaid_invoice": "Member invitations are blocked due to an unpaid invoice over 7 days past due.",
|
||||
"embeds": "Embeds",
|
||||
"impersonation": "Impersonation",
|
||||
"impersonation_description": "Settings to manage user impersonation",
|
||||
@@ -2180,8 +2184,8 @@
|
||||
"member_already_invited": "Member has already been invited",
|
||||
"already_in_use_error": "Username already in use",
|
||||
"enter_email_or_username": "Enter an email or username",
|
||||
"enter_email": "Enter an email",
|
||||
"enter_emails": "Enter emails",
|
||||
"enter_email": "Enter an email",
|
||||
"enter_emails": "Enter emails",
|
||||
"too_many_invites": "You are limited to inviting a maximum of {{nbUsers}} users at once.",
|
||||
"team_name_taken": "This name is already taken",
|
||||
"must_enter_team_name": "Must enter a team name",
|
||||
@@ -2934,6 +2938,7 @@
|
||||
"add_new_user": "Add new user",
|
||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||
"as_csv": "as CSV",
|
||||
"calendar_settings": "Calendar Settings",
|
||||
"overlay_my_calendar": "Overlay my calendar",
|
||||
"overlay_my_calendar_toc": "By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||
"view_overlay_calendar_events": "View your calendar events to prevent clashed booking.",
|
||||
@@ -3039,9 +3044,13 @@
|
||||
"time_range": "Time range",
|
||||
"automatically_add_all_team_members": "Add all team members, including future members",
|
||||
"redirect_to": "Redirect to",
|
||||
"having_trouble_finding_time": "Having trouble finding a time?",
|
||||
"show_more": "Show more",
|
||||
"forward_params_redirect": "Forward parameters such as ?email=...&name=.... and more",
|
||||
"having_trouble_finding_time": "Having trouble finding a time?",
|
||||
"show_more": "Show more",
|
||||
"signup_with_google": "Sign up with Google",
|
||||
"signup_with_microsoft": "Sign up with Microsoft",
|
||||
"signin_with_microsoft": "Sign in with Microsoft",
|
||||
"continue_with_microsoft": "Continue with Microsoft",
|
||||
"forward_params_redirect": "Forward parameters such as ?email=...&name=.... and more",
|
||||
"assignment_description": "Schedule meetings when everyone is available or rotate through members on your team",
|
||||
"lowest": "lowest",
|
||||
"low": "low",
|
||||
@@ -3877,6 +3886,8 @@
|
||||
"pbac_action_read_org_booking_audit_logs": "View organization booking audit logs",
|
||||
"pbac_action_impersonate": "Impersonate",
|
||||
"pbac_action_edit_users": "Edit users",
|
||||
"pbac_action_password_reset": "Reset password",
|
||||
"pbac_desc_reset_password_organization_members": "Send password reset emails to organization members",
|
||||
"role_created_successfully": "Role created successfully",
|
||||
"role_updated_successfully": "Role updated successfully",
|
||||
"delete_role": "Delete role",
|
||||
@@ -3955,7 +3966,7 @@
|
||||
"pbac_desc_view_routing_forms": "View routing forms",
|
||||
"pbac_desc_update_routing_forms": "Update routing forms",
|
||||
"pbac_desc_delete_routing_forms": "Delete routing forms",
|
||||
"pbac_opt_in_title":"Enable Roles & Permissions for your organization",
|
||||
"pbac_opt_in_title": "Enable Roles & Permissions for your organization",
|
||||
"pbac_opt_in_description": "Take control of your organization with customizable roles and granular permissions for your team members.",
|
||||
"pbac_opt_in_custom_roles_title": "Custom roles",
|
||||
"pbac_opt_in_custom_roles_desc": "Create custom roles tailored to your organization's unique workflow",
|
||||
@@ -4290,8 +4301,8 @@
|
||||
"onboarding_org_brand_subtitle": "Customize your booking pages with your logo and a custom banner",
|
||||
"onboarding_primary_color_label": "Primary color",
|
||||
"onboarding_banner_label": "Banner",
|
||||
"onboarding_logo_size_hint": "Recommended size 64x64px (max 10mb)",
|
||||
"onboarding_banner_size_hint": "Recommended size 1500x150px (max 10mb)",
|
||||
"onboarding_logo_size_hint": "Recommended size 64x64px (max 5mb)",
|
||||
"onboarding_banner_size_hint": "Recommended size 1500x500px (max 5mb)",
|
||||
"onboarding_preview_nameless": "Nameless",
|
||||
"onboarding_preview_example_title": "Enterprise sales",
|
||||
"onboarding_preview_example_description": "Jump on a call with one of our sales reps to learn more",
|
||||
@@ -4444,13 +4455,14 @@
|
||||
"reschedule_requested": "Reschedule requested",
|
||||
"attendee_added": "Attendee added",
|
||||
"host_no_show_updated": "Host no-show updated",
|
||||
"attendee_no_show_updated": "Attendee no-show updated",
|
||||
"no_show_updated": "No-show updated",
|
||||
"rejected": "Rejected",
|
||||
"attendee_removed": "Attendee removed",
|
||||
"reassignment": "Reassignment",
|
||||
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
|
||||
"location_changed": "Location changed",
|
||||
"location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}",
|
||||
"attendee_no_show_updated": "Attendee no-show updated",
|
||||
"seat_booked": "Seat booked",
|
||||
"seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
|
||||
"assignment_type": "Assignment",
|
||||
@@ -4490,10 +4502,11 @@
|
||||
"auto_opt_in_experimental_description_personal": "Automatically opt into new experimental features, unless disabled by your team or organization",
|
||||
"auto_opt_in_experimental_description_team": "Automatically opt team members into new experimental features, unless configured by the organization or opted out by members",
|
||||
"auto_opt_in_experimental_description_org": "Automatically opt organization members into new experimental features, unless configured at team or user level",
|
||||
"feature_blocked_by_team_warning": "This feature is disabled by your team.",
|
||||
"feature_blocked_by_org_warning": "This feature is disabled by your organization.",
|
||||
"feature_no_explicit_enablement_warning": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
|
||||
"feature_user_only_not_allowed_warning": "This feature needs approval from your team or organization.",
|
||||
"feature_org_disabled": "This feature is disabled by your organization.",
|
||||
"feature_all_teams_disabled": "This feature is disabled by your team.",
|
||||
"feature_any_team_disabled": "This feature is disabled by your team.",
|
||||
"feature_no_explicit_enablement": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
|
||||
"feature_user_only_not_allowed": "This feature needs approval from your team or organization.",
|
||||
"no_opt_in_features_available": "No opt-in features are currently available",
|
||||
"enabled_via_auto_opt_in": "Enabled via auto opt-in",
|
||||
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
|
||||
@@ -4516,5 +4529,6 @@
|
||||
"error_enabling_feature": "Error enabling feature. Please try again.",
|
||||
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
|
||||
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
"calendar_events_disabled_video_limitation": "When calendar events are disabled, meeting links cannot be generated for apps whose link generation is tied to calendar event creation, for example, Google Meet and Microsoft Teams. Other video apps like Cal Video will continue to work.",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const TRIGGER_VERSION = '20260129.1';
|
||||
@@ -358,6 +358,9 @@
|
||||
"includes": ["packages/platform/atoms/**/*.{ts,tsx,js,jsx,mts,mjs,cjs,cts}"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noProcessGlobal": "off"
|
||||
},
|
||||
"style": {
|
||||
"noRestrictedImports": {
|
||||
"level": "error",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from "node:process";
|
||||
import type { AppMeta } from "@calcom/types/App";
|
||||
import _package from "./package.json";
|
||||
|
||||
export const metadata = {
|
||||
name: "HubSpot CRM",
|
||||
// biome-ignore lint/correctness/noProcessGlobal: Server-only metadata evaluated at build time
|
||||
installed: !!process.env.HUBSPOT_CLIENT_ID,
|
||||
description: _package.description,
|
||||
type: "hubspot_crm",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/triple-slash-reference */
|
||||
/// <reference path="../../../types/ical.d.ts"/>
|
||||
import ICAL from "ical.js";
|
||||
|
||||
import process from "node:process";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import type {
|
||||
Calendar,
|
||||
IntegrationCalendar,
|
||||
EventBusyDate,
|
||||
CalendarEvent,
|
||||
EventBusyDate,
|
||||
GetAvailabilityParams,
|
||||
IntegrationCalendar,
|
||||
NewCalendarEventType,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import ICAL from "ical.js";
|
||||
|
||||
// for Apple's Travel Time feature only (for now)
|
||||
const getTravelDurationInSeconds = (vevent: ICAL.Component) => {
|
||||
@@ -301,7 +302,7 @@ class ICSFeedCalendarService implements Calendar {
|
||||
name,
|
||||
readOnly: true,
|
||||
externalId: url,
|
||||
integrationName: this.integrationName,
|
||||
integration: this.integrationName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
|
||||
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
|
||||
import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService";
|
||||
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -10,32 +12,39 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
attributeRoutingConfig,
|
||||
identifierKeyedResponse,
|
||||
action,
|
||||
routingTraceService,
|
||||
}: {
|
||||
attributeRoutingConfig: LocalRoute["attributeRoutingConfig"];
|
||||
identifierKeyedResponse: Record<string, string | string[]> | null;
|
||||
action: LocalRoute["action"];
|
||||
routingTraceService?: RoutingTraceService;
|
||||
}) {
|
||||
// Check if route is skipping CRM contact check
|
||||
if (attributeRoutingConfig?.skipContactOwner) return null;
|
||||
// Check if email is present
|
||||
|
||||
let prospectEmail: string | null = null;
|
||||
if (identifierKeyedResponse) {
|
||||
for (const identifier of Object.keys(identifierKeyedResponse)) {
|
||||
const fieldResponse = identifierKeyedResponse[identifier];
|
||||
if (identifier === "email") {
|
||||
prospectEmail = fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
|
||||
prospectEmail =
|
||||
fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prospectEmail) return null;
|
||||
|
||||
// Determine if the action is an event type redirect
|
||||
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId) return null;
|
||||
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId)
|
||||
return null;
|
||||
|
||||
const eventTypeId = action.eventTypeId;
|
||||
|
||||
const eventTypeRepo = new EventTypeRepository(prisma);
|
||||
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ id: action.eventTypeId });
|
||||
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
|
||||
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({
|
||||
id: action.eventTypeId,
|
||||
});
|
||||
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN)
|
||||
return null;
|
||||
|
||||
const eventTypeMetadata = eventType.metadata;
|
||||
if (!eventTypeMetadata) return null;
|
||||
@@ -51,34 +60,71 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
crmAppSlug: null,
|
||||
recordId: null,
|
||||
};
|
||||
// Determine if there is a CRM option enabled in the chosen route
|
||||
for (const appSlug of enabledAppSlugs) {
|
||||
const routingOptions = attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
|
||||
|
||||
if (!routingOptions) continue;
|
||||
// See if any options are true
|
||||
if (Object.values(routingOptions).some((option) => option === true)) {
|
||||
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
|
||||
.default;
|
||||
const appHandler = appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
|
||||
const runCrmOperations = async () => {
|
||||
for (const appSlug of enabledAppSlugs) {
|
||||
const routingOptions =
|
||||
attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
|
||||
|
||||
const ownerQuery = await appHandler(prospectEmail, attributeRoutingConfig, action.eventTypeId);
|
||||
if (!routingOptions) continue;
|
||||
|
||||
if (ownerQuery?.email) {
|
||||
contactOwner = { ...ownerQuery, crmAppSlug: appSlug };
|
||||
break;
|
||||
if (Object.values(routingOptions).some((option) => option === true)) {
|
||||
const appBookingFormHandler = (
|
||||
await import("@calcom/app-store/routing-forms/appBookingFormHandler")
|
||||
).default;
|
||||
const appHandler =
|
||||
appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
|
||||
|
||||
const ownerQuery = await appHandler(
|
||||
prospectEmail,
|
||||
attributeRoutingConfig,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
if (ownerQuery?.email) {
|
||||
contactOwner = { ...ownerQuery, crmAppSlug: appSlug };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
|
||||
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(
|
||||
prospectEmail,
|
||||
eventTypeMetadata
|
||||
);
|
||||
if (ownerQuery?.email) contactOwner = ownerQuery;
|
||||
}
|
||||
};
|
||||
|
||||
const crmRoutingTraceService = CrmRoutingTraceService.create(routingTraceService);
|
||||
|
||||
if (crmRoutingTraceService) {
|
||||
await crmRoutingTraceService.runAsync(runCrmOperations);
|
||||
} else {
|
||||
await runCrmOperations();
|
||||
}
|
||||
|
||||
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
|
||||
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(prospectEmail, eventTypeMetadata);
|
||||
if (ownerQuery?.email) contactOwner = ownerQuery;
|
||||
}
|
||||
if (!contactOwner) return null;
|
||||
|
||||
// Check that the contact owner is a part of the event type
|
||||
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email)) return null;
|
||||
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email))
|
||||
return null;
|
||||
|
||||
if (routingTraceService && contactOwner.email && contactOwner.crmAppSlug) {
|
||||
const salesforceSettings = attributeRoutingConfig?.salesforce;
|
||||
routingTraceService.addStep({
|
||||
domain: contactOwner.crmAppSlug,
|
||||
step: `${contactOwner.crmAppSlug}_assignment`,
|
||||
data: {
|
||||
email: contactOwner.email,
|
||||
recordType: contactOwner.recordType,
|
||||
recordId: contactOwner.recordId,
|
||||
crmAppSlug: contactOwner.crmAppSlug,
|
||||
rrSkipToAccountLookupField: salesforceSettings?.rrSkipToAccountLookupField,
|
||||
rrSKipToAccountLookupFieldName: salesforceSettings?.rrSKipToAccountLookupFieldName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return contactOwner;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
import { findMatchingRoute } from "./processRoute";
|
||||
|
||||
vi.mock("@calcom/lib/raqb/evaluateRaqbLogic", () => ({
|
||||
evaluateRaqbLogic: vi.fn(),
|
||||
RaqbLogicResult: {
|
||||
MATCH: "MATCH",
|
||||
NO_MATCH: "NO_MATCH",
|
||||
LOGIC_NOT_FOUND_SO_MATCHED: "LOGIC_NOT_FOUND_SO_MATCHED",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./getQueryBuilderConfig", () => ({
|
||||
getQueryBuilderConfigForFormFields: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const { evaluateRaqbLogic } = await import("@calcom/lib/raqb/evaluateRaqbLogic");
|
||||
|
||||
describe("findMatchingRoute", () => {
|
||||
let mockRoutingFormTrace: RoutingFormTraceService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRoutingFormTrace = {
|
||||
routeMatched: vi.fn(),
|
||||
fallbackRouteUsed: vi.fn(),
|
||||
attributeLogicEvaluated: vi.fn(),
|
||||
attributeFallbackUsed: vi.fn(),
|
||||
} as unknown as RoutingFormTraceService;
|
||||
});
|
||||
|
||||
const createMockForm = (
|
||||
routes: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
isFallback?: boolean;
|
||||
queryValue?: unknown;
|
||||
}>
|
||||
): Pick<SerializableForm<never>, "routes" | "fields"> => ({
|
||||
routes: routes.map((route) => ({
|
||||
id: route.id,
|
||||
name: route.name,
|
||||
isFallback: route.isFallback ?? false,
|
||||
queryValue: route.queryValue ?? { type: "group" },
|
||||
action: { type: "customPageMessage", value: "test" },
|
||||
})) as never,
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const createMockResponse = (): Record<string, Pick<FormResponse[string], "value">> => ({});
|
||||
|
||||
it("should throw error if fallback route is missing", () => {
|
||||
const form = createMockForm([{ id: "route-1", name: "Route 1" }]);
|
||||
|
||||
expect(() =>
|
||||
findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
})
|
||||
).toThrow("Fallback route is missing");
|
||||
});
|
||||
|
||||
it("should return null if no route matches", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.NO_MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Route 1" },
|
||||
{ id: "fallback", name: "Fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("tracing", () => {
|
||||
it("should call routeMatched when a non-fallback route matches", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", name: "Default", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
|
||||
routeId: "route-1",
|
||||
routeName: "Sales Route",
|
||||
});
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call fallbackRouteUsed when fallback route is used", () => {
|
||||
vi.mocked(evaluateRaqbLogic)
|
||||
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
|
||||
.mockReturnValueOnce(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", name: "Default Route", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
|
||||
routeId: "fallback",
|
||||
routeName: "Default Route",
|
||||
});
|
||||
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use 'default_route' as name when fallback route has no name", () => {
|
||||
vi.mocked(evaluateRaqbLogic)
|
||||
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
|
||||
.mockReturnValueOnce(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
|
||||
routeId: "fallback",
|
||||
routeName: "default_route",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use route id as name when route has no name", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-123" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
|
||||
routeId: "route-123",
|
||||
routeName: "route-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call trace methods when routingFormTrace is not provided", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle LOGIC_NOT_FOUND_SO_MATCHED as a match", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Auto Match Route" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
|
||||
routeId: "route-1",
|
||||
routeName: "Auto Match Route",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { JsonTree } from "react-awesome-query-builder";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import { evaluateRaqbLogic, RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
|
||||
import type { JsonTree } from "react-awesome-query-builder";
|
||||
import type { z } from "zod";
|
||||
import type { FormResponse, Route, SerializableForm } from "../types/types";
|
||||
import type { zodNonRouterRoute } from "../zod";
|
||||
import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig";
|
||||
@@ -15,9 +14,11 @@ import isRouter from "./isRouter";
|
||||
export function findMatchingRoute({
|
||||
form,
|
||||
response,
|
||||
routingFormTraceService,
|
||||
}: {
|
||||
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
|
||||
response: Record<string, Pick<FormResponse[string], "value">>;
|
||||
routingFormTraceService?: RoutingFormTraceService;
|
||||
}) {
|
||||
const queryBuilderConfig = getQueryBuilderConfigForFormFields(form);
|
||||
|
||||
@@ -67,5 +68,21 @@ export function findMatchingRoute({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (routingFormTraceService) {
|
||||
let routeName: string;
|
||||
if ("name" in chosenRoute && chosenRoute.name) {
|
||||
routeName = chosenRoute.name;
|
||||
} else if (isFallbackRoute(chosenRoute)) {
|
||||
routeName = "default_route";
|
||||
} else {
|
||||
routeName = chosenRoute.id;
|
||||
}
|
||||
if (isFallbackRoute(chosenRoute)) {
|
||||
routingFormTraceService.fallbackRouteUsed({ routeId: chosenRoute.id, routeName });
|
||||
} else {
|
||||
routingFormTraceService.routeMatched({ routeId: chosenRoute.id, routeName });
|
||||
}
|
||||
}
|
||||
|
||||
return chosenRoute;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
|
||||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import { SalesforceRoutingTraceService } from "./tracing";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import { findFieldValueByIdentifier } from "../../routing-forms/lib/findFieldValueByIdentifier";
|
||||
import { default as appMeta } from "../config.json";
|
||||
@@ -636,9 +637,15 @@ class SalesforceCRMService implements CRM {
|
||||
}
|
||||
}
|
||||
|
||||
const includeOwnerData =
|
||||
(includeOwner || forRoundRobinSkip) &&
|
||||
!(await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]));
|
||||
const isFreeEmailDomain = await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]);
|
||||
const includeOwnerData = (includeOwner || forRoundRobinSkip) && !isFreeEmailDomain;
|
||||
|
||||
if (isFreeEmailDomain && (includeOwner || forRoundRobinSkip)) {
|
||||
SalesforceRoutingTraceService.ownerLookupSkipped({
|
||||
reason: "Free email domain",
|
||||
email: emailArray[0],
|
||||
});
|
||||
}
|
||||
|
||||
const includeAccountRecordType = forRoundRobinSkip && recordToSearch === SalesforceRecordEnum.ACCOUNT;
|
||||
return records.map((record) => {
|
||||
@@ -649,6 +656,33 @@ class SalesforceCRMService implements CRM {
|
||||
? record?.Account?.Owner?.Email
|
||||
: record?.Owner?.Email;
|
||||
|
||||
// Trace owner lookup based on record type
|
||||
if (includeOwnerData && record?.Id && record?.OwnerId) {
|
||||
const recordType = record?.attributes?.type;
|
||||
if (recordType === SalesforceRecordEnum.CONTACT) {
|
||||
SalesforceRoutingTraceService.contactOwnerLookup({
|
||||
contactId: record.Id,
|
||||
ownerEmail: ownerEmail ?? null,
|
||||
ownerId: record.OwnerId ?? null,
|
||||
});
|
||||
} else if (recordType === SalesforceRecordEnum.LEAD) {
|
||||
SalesforceRoutingTraceService.leadOwnerLookup({
|
||||
leadId: record.Id,
|
||||
ownerEmail: ownerEmail ?? null,
|
||||
ownerId: record.OwnerId ?? null,
|
||||
});
|
||||
} else if (
|
||||
recordType === SalesforceRecordEnum.ACCOUNT ||
|
||||
(includeAccountRecordType && record?.AccountId)
|
||||
) {
|
||||
SalesforceRoutingTraceService.accountOwnerLookup({
|
||||
accountId: record.AccountId || record.Id,
|
||||
ownerEmail: ownerEmail ?? null,
|
||||
ownerId: record.OwnerId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: includeAccountRecordType ? record?.AccountId || "" : record?.Id || "",
|
||||
email: record?.Email || "",
|
||||
@@ -1102,6 +1136,11 @@ class SalesforceCRMService implements CRM {
|
||||
const emailDomain = email.split("@")[1];
|
||||
const log = logger.getSubLogger({ prefix: [`[getAccountIdBasedOnEmailDomainOfContacts]:${email}`] });
|
||||
log.info("getAccountIdBasedOnEmailDomainOfContacts", safeStringify({ email, emailDomain }));
|
||||
|
||||
SalesforceRoutingTraceService.searchingByWebsiteValue({
|
||||
emailDomain,
|
||||
});
|
||||
|
||||
// First check if an account has the same website as the email domain of the attendee
|
||||
const accountQuery = await conn.query(
|
||||
`SELECT Id, Website FROM Account WHERE Website IN (${this.getAllPossibleAccountWebsiteFromEmailDomain(
|
||||
@@ -1114,6 +1153,10 @@ class SalesforceCRMService implements CRM {
|
||||
"Found account based on email domain",
|
||||
safeStringify({ emailDomain, accountWebsite: account.Website, accountId: account.Id })
|
||||
);
|
||||
SalesforceRoutingTraceService.accountFoundByWebsite({
|
||||
accountId: account.Id,
|
||||
website: account.Website,
|
||||
});
|
||||
return account.Id;
|
||||
}
|
||||
|
||||
@@ -1122,12 +1165,28 @@ class SalesforceCRMService implements CRM {
|
||||
`SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${emailDomain}' AND AccountId != null`
|
||||
);
|
||||
|
||||
SalesforceRoutingTraceService.searchingByContactEmailDomain({
|
||||
emailDomain,
|
||||
contactCount: response.records.length,
|
||||
});
|
||||
|
||||
const accountId = this.getDominantAccountId(response.records as { AccountId: string }[]);
|
||||
|
||||
if (accountId) {
|
||||
log.info("Found account based on other contacts", safeStringify({ accountId }));
|
||||
const contactsUnderAccount = (response.records as { AccountId: string }[]).filter(
|
||||
(r) => r.AccountId === accountId
|
||||
);
|
||||
SalesforceRoutingTraceService.accountSelectedByMostContacts({
|
||||
accountId,
|
||||
contactCount: contactsUnderAccount.length,
|
||||
});
|
||||
} else {
|
||||
log.info("No account found");
|
||||
SalesforceRoutingTraceService.noAccountFound({
|
||||
email,
|
||||
reason: "No account found by website or contact domain",
|
||||
});
|
||||
}
|
||||
|
||||
return accountId;
|
||||
@@ -1669,6 +1728,12 @@ class SalesforceCRMService implements CRM {
|
||||
if (salesforceObject === SalesforceRecordEnum.ACCOUNT) {
|
||||
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
|
||||
|
||||
SalesforceRoutingTraceService.lookupFieldQuery({
|
||||
fieldName,
|
||||
salesforceObject,
|
||||
accountId: accountId ?? null,
|
||||
});
|
||||
|
||||
if (!accountId) return;
|
||||
|
||||
const accountQuery = (await conn.query(
|
||||
@@ -1691,6 +1756,11 @@ class SalesforceCRMService implements CRM {
|
||||
|
||||
const user = userQuery.records[0] as { Email: string };
|
||||
|
||||
SalesforceRoutingTraceService.userQueryFromLookupField({
|
||||
lookupFieldUserId,
|
||||
userEmail: user.Email,
|
||||
});
|
||||
|
||||
return { email: user.Email, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { Contact } from "@calcom/types/CrmService";
|
||||
|
||||
import { SalesforceRecordEnum } from "../enums";
|
||||
import { SalesforceRoutingTraceService } from "../tracing";
|
||||
import getAllPossibleWebsiteValuesFromEmailDomain from "../utils/getAllPossibleWebsiteValuesFromEmailDomain";
|
||||
import getDominantAccountId from "../utils/getDominantAccountId";
|
||||
import { GetAccountRecordsForRRSkip } from "./documents/queries";
|
||||
@@ -60,6 +61,12 @@ export class SalesforceGraphQLClient {
|
||||
const emailDomain = email.split("@")[1];
|
||||
const websites = this.getAllPossibleAccountWebsiteFromEmailDomain(emailDomain);
|
||||
|
||||
// Trace query initiation
|
||||
SalesforceRoutingTraceService.graphqlQueryInitiated({
|
||||
email,
|
||||
emailDomain,
|
||||
});
|
||||
|
||||
log.info(`Query against email and email domain of ${emailDomain}`);
|
||||
|
||||
const query = await this.client.query(GetAccountRecordsForRRSkip, {
|
||||
@@ -91,6 +98,11 @@ export class SalesforceGraphQLClient {
|
||||
|
||||
if (contact) {
|
||||
log.info(`Existing contact found with id ${contact.Id}`);
|
||||
SalesforceRoutingTraceService.graphqlExistingContactFound({
|
||||
contactId: contact.Id,
|
||||
accountId: contact.AccountId?.value || contact.Id,
|
||||
ownerEmail: contact.Account?.Owner?.Email?.value || "",
|
||||
});
|
||||
return [
|
||||
{
|
||||
id: contact.AccountId?.value || contact.Id,
|
||||
@@ -110,6 +122,10 @@ export class SalesforceGraphQLClient {
|
||||
log.info(
|
||||
`Existing account with website that matches email domain of ${emailDomain} found with id ${account.Id}`
|
||||
);
|
||||
SalesforceRoutingTraceService.graphqlAccountFoundByWebsite({
|
||||
accountId: account.Id,
|
||||
ownerEmail: account.Owner?.Email?.value || "",
|
||||
});
|
||||
return [
|
||||
{
|
||||
id: account.Id,
|
||||
@@ -158,6 +174,12 @@ export class SalesforceGraphQLClient {
|
||||
return contacts;
|
||||
}, [] as { id: string; AccountId: string; ownerId: string; ownerEmail: string }[]);
|
||||
|
||||
// Trace searching by contact domain
|
||||
SalesforceRoutingTraceService.graphqlSearchingByContactDomain({
|
||||
emailDomain,
|
||||
contactCount: relatedContacts.length,
|
||||
});
|
||||
|
||||
const dominantAccountId = getDominantAccountId(relatedContacts);
|
||||
|
||||
if (!dominantAccountId) {
|
||||
@@ -165,6 +187,10 @@ export class SalesforceGraphQLClient {
|
||||
"Could not find dominant account id with the following contacts",
|
||||
safeStringify({ relatedContacts })
|
||||
);
|
||||
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
||||
email,
|
||||
reason: "Could not find dominant account from related contacts",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -180,6 +206,18 @@ export class SalesforceGraphQLClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Trace dominant account selection
|
||||
const contactsUnderDominantAccount = relatedContacts.filter(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
(contact) => contact.AccountId === dominantAccountId
|
||||
);
|
||||
SalesforceRoutingTraceService.graphqlDominantAccountSelected({
|
||||
accountId: dominantAccountId,
|
||||
contactCount: contactsUnderDominantAccount.length,
|
||||
ownerEmail: contactUnderAccount.ownerEmail,
|
||||
});
|
||||
|
||||
log.info(`Account found via related contacts with account id ${dominantAccountId}`);
|
||||
return [
|
||||
{
|
||||
@@ -193,6 +231,10 @@ export class SalesforceGraphQLClient {
|
||||
}
|
||||
|
||||
log.info("No account found for attendee");
|
||||
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
||||
email,
|
||||
reason: "No account found via any tier",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService";
|
||||
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService";
|
||||
|
||||
describe("SalesforceRoutingTraceService", () => {
|
||||
let mockParentTraceService: RoutingTraceService;
|
||||
let addStepSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockParentTraceService(): RoutingTraceService {
|
||||
return {
|
||||
addStep: vi.fn(),
|
||||
getTrace: vi.fn().mockReturnValue([]),
|
||||
} as unknown as RoutingTraceService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to run a function within the CRM trace context.
|
||||
* This sets up AsyncLocalStorage so SalesforceRoutingTraceService methods work.
|
||||
*/
|
||||
async function runWithTraceContext<T>(fn: () => T): Promise<T> {
|
||||
const crmTrace = CrmRoutingTraceService.create(mockParentTraceService);
|
||||
if (!crmTrace) throw new Error("Failed to create CRM trace service");
|
||||
return crmTrace.runAsync(async () => fn());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockParentTraceService = createMockParentTraceService();
|
||||
addStepSpy = mockParentTraceService.addStep as ReturnType<typeof vi.fn>;
|
||||
});
|
||||
|
||||
describe("when called outside trace context", () => {
|
||||
it("should not throw when called outside trace context", () => {
|
||||
expect(() => {
|
||||
SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "example.com" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle all methods gracefully when called outside trace context", () => {
|
||||
expect(() => {
|
||||
SalesforceRoutingTraceService.accountFoundByWebsite({
|
||||
accountId: "123",
|
||||
website: "example.com",
|
||||
});
|
||||
SalesforceRoutingTraceService.searchingByContactEmailDomain({
|
||||
emailDomain: "example.com",
|
||||
contactCount: 5,
|
||||
});
|
||||
SalesforceRoutingTraceService.noAccountFound({ email: "test@example.com", reason: "test" });
|
||||
SalesforceRoutingTraceService.graphqlQueryInitiated({
|
||||
email: "test@example.com",
|
||||
emailDomain: "example.com",
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Account Resolution (SOQL path)", () => {
|
||||
describe("searchingByWebsiteValue", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "acme.com" });
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "searching_by_website_value",
|
||||
data: { emailDomain: "acme.com" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountFoundByWebsite", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.accountFoundByWebsite({
|
||||
accountId: "001ABC123",
|
||||
website: "www.acme.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "account_found_by_website",
|
||||
data: { accountId: "001ABC123", website: "www.acme.com" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchingByContactEmailDomain", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.searchingByContactEmailDomain({
|
||||
emailDomain: "acme.com",
|
||||
contactCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "searching_by_contact_email_domain",
|
||||
data: { emailDomain: "acme.com", contactCount: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountSelectedByMostContacts", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.accountSelectedByMostContacts({
|
||||
accountId: "001ABC123",
|
||||
contactCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "account_selected_by_most_contacts",
|
||||
data: { accountId: "001ABC123", contactCount: 5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("noAccountFound", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.noAccountFound({
|
||||
email: "user@unknown.com",
|
||||
reason: "No account found by website or contact domain",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "no_account_found",
|
||||
data: { email: "user@unknown.com", reason: "No account found by website or contact domain" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lookup Field", () => {
|
||||
describe("lookupFieldQuery", () => {
|
||||
it("should add step with correct domain and data when accountId is present", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.lookupFieldQuery({
|
||||
fieldName: "Account_Owner__c",
|
||||
salesforceObject: "Account",
|
||||
accountId: "001ABC123",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "lookup_field_query",
|
||||
data: { fieldName: "Account_Owner__c", salesforceObject: "Account", accountId: "001ABC123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with null accountId", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.lookupFieldQuery({
|
||||
fieldName: "Account_Owner__c",
|
||||
salesforceObject: "Account",
|
||||
accountId: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "lookup_field_query",
|
||||
data: { fieldName: "Account_Owner__c", salesforceObject: "Account", accountId: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userQueryFromLookupField", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.userQueryFromLookupField({
|
||||
lookupFieldUserId: "005ABC123",
|
||||
userEmail: "owner@acme.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "user_query_from_lookup_field",
|
||||
data: { lookupFieldUserId: "005ABC123", userEmail: "owner@acme.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with null userEmail", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.userQueryFromLookupField({
|
||||
lookupFieldUserId: "005ABC123",
|
||||
userEmail: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "user_query_from_lookup_field",
|
||||
data: { lookupFieldUserId: "005ABC123", userEmail: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Owner Lookups", () => {
|
||||
describe("contactOwnerLookup", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.contactOwnerLookup({
|
||||
contactId: "003ABC123",
|
||||
ownerEmail: "owner@acme.com",
|
||||
ownerId: "005DEF456",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "contact_owner_lookup",
|
||||
data: { contactId: "003ABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with null owner data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.contactOwnerLookup({
|
||||
contactId: "003ABC123",
|
||||
ownerEmail: null,
|
||||
ownerId: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "contact_owner_lookup",
|
||||
data: { contactId: "003ABC123", ownerEmail: null, ownerId: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("leadOwnerLookup", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.leadOwnerLookup({
|
||||
leadId: "00QABC123",
|
||||
ownerEmail: "owner@acme.com",
|
||||
ownerId: "005DEF456",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "lead_owner_lookup",
|
||||
data: { leadId: "00QABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with null owner data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.leadOwnerLookup({
|
||||
leadId: "00QABC123",
|
||||
ownerEmail: null,
|
||||
ownerId: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "lead_owner_lookup",
|
||||
data: { leadId: "00QABC123", ownerEmail: null, ownerId: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountOwnerLookup", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.accountOwnerLookup({
|
||||
accountId: "001ABC123",
|
||||
ownerEmail: "owner@acme.com",
|
||||
ownerId: "005DEF456",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "account_owner_lookup",
|
||||
data: { accountId: "001ABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with null owner data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.accountOwnerLookup({
|
||||
accountId: "001ABC123",
|
||||
ownerEmail: null,
|
||||
ownerId: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "account_owner_lookup",
|
||||
data: { accountId: "001ABC123", ownerEmail: null, ownerId: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
describe("ownerValidated", () => {
|
||||
it("should add step when owner is a team member", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.ownerValidated({
|
||||
ownerEmail: "owner@acme.com",
|
||||
isTeamMember: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "owner_validated",
|
||||
data: { ownerEmail: "owner@acme.com", isTeamMember: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step when owner is not a team member", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.ownerValidated({
|
||||
ownerEmail: "external@other.com",
|
||||
isTeamMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "owner_validated",
|
||||
data: { ownerEmail: "external@other.com", isTeamMember: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skip", () => {
|
||||
describe("ownerLookupSkipped", () => {
|
||||
it("should add step with reason and email", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.ownerLookupSkipped({
|
||||
reason: "Free email domain",
|
||||
email: "user@gmail.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "owner_lookup_skipped",
|
||||
data: { reason: "Free email domain", email: "user@gmail.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with reason only (no email)", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.ownerLookupSkipped({
|
||||
reason: "No CRM configured",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "owner_lookup_skipped",
|
||||
data: { reason: "No CRM configured" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("contactOwnerCheckSkipped", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.contactOwnerCheckSkipped({
|
||||
reason: "Route config disabled contact owner check",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "contact_owner_check_skipped",
|
||||
data: { reason: "Route config disabled contact owner check" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GraphQL-specific", () => {
|
||||
describe("graphqlQueryInitiated", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlQueryInitiated({
|
||||
email: "user@acme.com",
|
||||
emailDomain: "acme.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_query_initiated",
|
||||
data: { email: "user@acme.com", emailDomain: "acme.com" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphqlExistingContactFound", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlExistingContactFound({
|
||||
contactId: "003ABC123",
|
||||
accountId: "001DEF456",
|
||||
ownerEmail: "owner@acme.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_existing_contact_found",
|
||||
data: { contactId: "003ABC123", accountId: "001DEF456", ownerEmail: "owner@acme.com" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphqlAccountFoundByWebsite", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlAccountFoundByWebsite({
|
||||
accountId: "001ABC123",
|
||||
ownerEmail: "owner@acme.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_account_found_by_website",
|
||||
data: { accountId: "001ABC123", ownerEmail: "owner@acme.com" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphqlSearchingByContactDomain", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlSearchingByContactDomain({
|
||||
emailDomain: "acme.com",
|
||||
contactCount: 15,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_searching_by_contact_domain",
|
||||
data: { emailDomain: "acme.com", contactCount: 15 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphqlDominantAccountSelected", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlDominantAccountSelected({
|
||||
accountId: "001ABC123",
|
||||
contactCount: 8,
|
||||
ownerEmail: "owner@acme.com",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_dominant_account_selected",
|
||||
data: { accountId: "001ABC123", contactCount: 8, ownerEmail: "owner@acme.com" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphqlNoAccountFound", () => {
|
||||
it("should add step with correct domain and data", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
||||
email: "user@unknown.com",
|
||||
reason: "No account found via any tier",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_no_account_found",
|
||||
data: { email: "user@unknown.com", reason: "No account found via any tier" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should add step with different reason", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
||||
email: "user@example.com",
|
||||
reason: "Could not find dominant account from related contacts",
|
||||
});
|
||||
});
|
||||
|
||||
expect(addStepSpy).toHaveBeenCalledWith({
|
||||
domain: "salesforce",
|
||||
step: "graphql_no_account_found",
|
||||
data: { email: "user@example.com", reason: "Could not find dominant account from related contacts" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("domain constant", () => {
|
||||
it("should use 'salesforce' as the domain for all methods", async () => {
|
||||
await runWithTraceContext(() => {
|
||||
SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "test.com" });
|
||||
SalesforceRoutingTraceService.accountFoundByWebsite({
|
||||
accountId: "123",
|
||||
website: "test.com",
|
||||
});
|
||||
SalesforceRoutingTraceService.contactOwnerLookup({
|
||||
contactId: "456",
|
||||
ownerEmail: "test@test.com",
|
||||
ownerId: "789",
|
||||
});
|
||||
SalesforceRoutingTraceService.graphqlQueryInitiated({
|
||||
email: "test@test.com",
|
||||
emailDomain: "test.com",
|
||||
});
|
||||
});
|
||||
|
||||
const calls = addStepSpy.mock.calls;
|
||||
calls.forEach((call: [{ domain: string; step: string; data: Record<string, unknown> }]) => {
|
||||
expect(call[0].domain).toBe("salesforce");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService";
|
||||
|
||||
const DOMAIN = "salesforce";
|
||||
|
||||
/**
|
||||
* Static class providing trace methods for Salesforce CRM routing operations.
|
||||
* Uses AsyncLocalStorage to automatically get the current trace context.
|
||||
* Methods are no-ops if called outside a trace context.
|
||||
*/
|
||||
export class SalesforceRoutingTraceService {
|
||||
private static addStep(step: string, data: Record<string, unknown> = {}): void {
|
||||
CrmRoutingTraceService.getCurrent()?.addStep(DOMAIN, step, data);
|
||||
}
|
||||
|
||||
// ===== Account Resolution (SOQL path) =====
|
||||
|
||||
/**
|
||||
* Record when searching for account by website field (matching email domain).
|
||||
*/
|
||||
static searchingByWebsiteValue(data: { emailDomain: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("searching_by_website_value", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when an account is found by website match.
|
||||
*/
|
||||
static accountFoundByWebsite(data: { accountId: string; website: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("account_found_by_website", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when searching for account via contacts with same email domain.
|
||||
*/
|
||||
static searchingByContactEmailDomain(data: { emailDomain: string; contactCount: number }): void {
|
||||
SalesforceRoutingTraceService.addStep("searching_by_contact_email_domain", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when account is selected based on having most contacts.
|
||||
*/
|
||||
static accountSelectedByMostContacts(data: { accountId: string; contactCount: number }): void {
|
||||
SalesforceRoutingTraceService.addStep("account_selected_by_most_contacts", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when no account could be found.
|
||||
*/
|
||||
static noAccountFound(data: { email: string; reason: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("no_account_found", data);
|
||||
}
|
||||
|
||||
// ===== Lookup Field =====
|
||||
|
||||
/**
|
||||
* Record lookup field query execution.
|
||||
*/
|
||||
static lookupFieldQuery(data: {
|
||||
fieldName: string;
|
||||
salesforceObject: string;
|
||||
accountId: string | null;
|
||||
}): void {
|
||||
SalesforceRoutingTraceService.addStep("lookup_field_query", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user query from lookup field result.
|
||||
*/
|
||||
static userQueryFromLookupField(data: { lookupFieldUserId: string; userEmail: string | null }): void {
|
||||
SalesforceRoutingTraceService.addStep("user_query_from_lookup_field", data);
|
||||
}
|
||||
|
||||
// ===== Owner Lookups =====
|
||||
|
||||
/**
|
||||
* Record contact owner lookup.
|
||||
*/
|
||||
static contactOwnerLookup(data: {
|
||||
contactId: string;
|
||||
ownerEmail: string | null;
|
||||
ownerId: string | null;
|
||||
}): void {
|
||||
SalesforceRoutingTraceService.addStep("contact_owner_lookup", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record lead owner lookup.
|
||||
*/
|
||||
static leadOwnerLookup(data: { leadId: string; ownerEmail: string | null; ownerId: string | null }): void {
|
||||
SalesforceRoutingTraceService.addStep("lead_owner_lookup", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record account owner lookup.
|
||||
*/
|
||||
static accountOwnerLookup(data: {
|
||||
accountId: string;
|
||||
ownerEmail: string | null;
|
||||
ownerId: string | null;
|
||||
}): void {
|
||||
SalesforceRoutingTraceService.addStep("account_owner_lookup", data);
|
||||
}
|
||||
|
||||
// ===== Validation =====
|
||||
|
||||
/**
|
||||
* Record when owner is validated as a team member.
|
||||
*/
|
||||
static ownerValidated(data: { ownerEmail: string; isTeamMember: boolean }): void {
|
||||
SalesforceRoutingTraceService.addStep("owner_validated", data);
|
||||
}
|
||||
|
||||
// ===== Skip =====
|
||||
|
||||
/**
|
||||
* Record when owner lookup is skipped (e.g., free email domain).
|
||||
*/
|
||||
static ownerLookupSkipped(data: { reason: string; email?: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("owner_lookup_skipped", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when contact owner check is skipped due to route config.
|
||||
*/
|
||||
static contactOwnerCheckSkipped(data: { reason: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("contact_owner_check_skipped", data);
|
||||
}
|
||||
|
||||
// ===== GraphQL-specific (resolution in GetAccountRecordsForRRSkip) =====
|
||||
|
||||
/**
|
||||
* Record when GraphQL query is initiated for account resolution.
|
||||
*/
|
||||
static graphqlQueryInitiated(data: { email: string; emailDomain: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("graphql_query_initiated", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when existing contact is found and its account is used.
|
||||
*/
|
||||
static graphqlExistingContactFound(data: {
|
||||
contactId: string;
|
||||
accountId: string;
|
||||
ownerEmail: string;
|
||||
}): void {
|
||||
SalesforceRoutingTraceService.addStep("graphql_existing_contact_found", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when account found by website match.
|
||||
*/
|
||||
static graphqlAccountFoundByWebsite(data: { accountId: string; ownerEmail: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("graphql_account_found_by_website", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when searching related contacts by email domain.
|
||||
*/
|
||||
static graphqlSearchingByContactDomain(data: { emailDomain: string; contactCount: number }): void {
|
||||
SalesforceRoutingTraceService.addStep("graphql_searching_by_contact_domain", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when dominant account is selected from related contacts.
|
||||
*/
|
||||
static graphqlDominantAccountSelected(data: {
|
||||
accountId: string;
|
||||
contactCount: number;
|
||||
ownerEmail: string;
|
||||
}): void {
|
||||
SalesforceRoutingTraceService.addStep("graphql_dominant_account_selected", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when no account is found via GraphQL.
|
||||
*/
|
||||
static graphqlNoAccountFound(data: { email: string; reason: string }): void {
|
||||
SalesforceRoutingTraceService.addStep("graphql_no_account_found", data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService";
|
||||
@@ -122,12 +122,14 @@ The embed system carefully manages visibility to prevent visual glitches:
|
||||
- Indicates: Booker has been reopened after modal was closed
|
||||
- Triggers: On subsequent linkReady events (viewId > 1) when modal is reopened without reload
|
||||
- Note: Distinguishes between first view (bookerViewed) and reopen (bookerReopened). Uses viewId to determine if it's a reopen.
|
||||
- Applicability: Only applicable for prerendered modals that are now visible.
|
||||
|
||||
13. **bookerReloaded Event**
|
||||
- Fired by: Iframe
|
||||
- Indicates: Booker has been reloaded (full page reload within modal)
|
||||
- Triggers: On linkReady after fullReload action is taken (when reloadInitiated flag is set)
|
||||
- Note: Distinguishes between first view (bookerViewed), reopen (bookerReopened), and reload (bookerReloaded). Fires only once per reload.
|
||||
- Applicability: Only applicable for prerendered modals that are now visible.
|
||||
|
||||
14. **bookerReady Event**
|
||||
- Fired by: Iframe
|
||||
@@ -223,4 +225,4 @@ The embed system tracks user interactions and page views:
|
||||
Page Load Errors:
|
||||
- System monitors CalComPageStatus
|
||||
- On non-200 status: fires linkFailed event
|
||||
- Includes error code and URL information
|
||||
- Includes error code and URL information
|
||||
|
||||
@@ -321,6 +321,13 @@ test.describe("Popup Tests", () => {
|
||||
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
|
||||
pathname: "/free/30min",
|
||||
});
|
||||
|
||||
if (!embedIframe) {
|
||||
throw new Error("Embed iframe not found");
|
||||
}
|
||||
|
||||
// Verify that event type details are hidden (hideEventTypeDetails: true was set via ui() call)
|
||||
await expect(embedIframe.locator('[data-testid="event-meta"]')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
/// <reference types="../env" />
|
||||
import { FloatingButton } from "./FloatingButton/FloatingButton";
|
||||
import { Inline } from "./Inline/inline";
|
||||
import { ModalBox } from "./ModalBox/ModalBox";
|
||||
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
|
||||
} from "./constants";
|
||||
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
|
||||
import css from "./embed.css?inline";
|
||||
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
|
||||
import { FloatingButton } from "./FloatingButton/FloatingButton";
|
||||
import { Inline } from "./Inline/inline";
|
||||
import { getScrollableAncestor } from "./lib/domUtils";
|
||||
import { getScrollByDistanceHandler } from "./lib/eventHandlers/scrollByDistanceEventHandler";
|
||||
import {
|
||||
buildConfigWithPrerenderRelatedFields,
|
||||
fromEntriesWithDuplicateKeys,
|
||||
isRouterPath,
|
||||
generateDataAttributes,
|
||||
getConfigProp,
|
||||
isRouterPath,
|
||||
isSameBookingLink,
|
||||
buildConfigWithPrerenderRelatedFields,
|
||||
} from "./lib/utils";
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
import { ModalBox } from "./ModalBox/ModalBox";
|
||||
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
import tailwindCss from "./tailwindCss";
|
||||
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
|
||||
import type { EmbedPageType, ModalPrerenderOptions, PrefillAndIframeAttrsConfig, UiConfig } from "./types";
|
||||
import { getMaxHeightForModal } from "./ui-utils";
|
||||
|
||||
// Exporting for consumption by @calcom/embed-core user
|
||||
export type { EmbedEvent } from "./sdk-action-manager";
|
||||
export type { PrefillAndIframeAttrsConfig } from "./types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Rest<T extends any[] | undefined> = T extends [any, ...infer U] ? U : never;
|
||||
export type Message = {
|
||||
@@ -177,8 +179,8 @@ type SingleInstructionMap = {
|
||||
off: ["off", allPossibleCallbacksAndActions];
|
||||
} & {
|
||||
[K in Exclude<keyof CalApi, "on" | "off">]: CalApi[K] extends (...args: never[]) => void
|
||||
? [K, ...Parameters<CalApi[K]>]
|
||||
: never;
|
||||
? [K, ...Parameters<CalApi[K]>]
|
||||
: never;
|
||||
};
|
||||
|
||||
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
|
||||
@@ -231,6 +233,11 @@ export class Cal {
|
||||
|
||||
api: CalApi;
|
||||
|
||||
/**
|
||||
* Commands for which the queue persists across iframe resets
|
||||
*/
|
||||
commandsPersistAcrossIframeResets: DoInIframeArg['method'][] = ["ui"];
|
||||
|
||||
isPrerendering?: boolean;
|
||||
|
||||
static actionsManagers: Record<Namespace, SdkActionManager>;
|
||||
@@ -361,14 +368,13 @@ export class Cal {
|
||||
}
|
||||
|
||||
// Merge searchParams from config onto the URL which might have query params already
|
||||
// @ts-ignore TS2802: URLSearchParams iteration requires downlevelIteration
|
||||
for (const [key, value] of searchParams) {
|
||||
searchParams.forEach((value, key) => {
|
||||
urlInstance.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Very Important:Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check when it is ready.
|
||||
// Very Important: Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check correctly when it is ready.
|
||||
this.iframeReset();
|
||||
|
||||
|
||||
if (iframe.src === urlInstance.toString()) {
|
||||
// Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it
|
||||
// This param has no other purpose except to ensure forced reload.
|
||||
@@ -408,7 +414,10 @@ export class Cal {
|
||||
|
||||
iframeReset() {
|
||||
this.iframeReady = false;
|
||||
this.iframeDoQueue = [];
|
||||
// Only keep UI related instructions in the queue, as we want to ensure that newly loaded iframe has the same UI configuration applied automatically
|
||||
this.iframeDoQueue = this.iframeDoQueue.filter((doInIframeArg) =>
|
||||
this.commandsPersistAcrossIframeResets.includes(doInIframeArg.method)
|
||||
);
|
||||
}
|
||||
|
||||
constructor(namespace: string, q: Queue) {
|
||||
@@ -604,7 +613,7 @@ export class Cal {
|
||||
: 0;
|
||||
const crossedReloadThreshold = previousEmbedRenderStartTime
|
||||
? timeSinceLastRender >
|
||||
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
|
||||
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
|
||||
: false;
|
||||
|
||||
const areSlotsStale = previousEmbedRenderStartTime
|
||||
@@ -862,7 +871,7 @@ class CalApi {
|
||||
|
||||
CalApi.initializedNamespaces.push(this.cal.namespace);
|
||||
|
||||
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
|
||||
const { calOrigin, origin, ...restConfig } = config;
|
||||
|
||||
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
|
||||
|
||||
@@ -950,10 +959,10 @@ class CalApi {
|
||||
|
||||
template.innerHTML = `<cal-inline
|
||||
${generateDataAttributes({
|
||||
pageType,
|
||||
theme,
|
||||
layout,
|
||||
})}
|
||||
pageType,
|
||||
theme,
|
||||
layout,
|
||||
})}
|
||||
style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%">
|
||||
</cal-inline>
|
||||
<style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
|
||||
@@ -1187,7 +1196,7 @@ class CalApi {
|
||||
config: enrichedConfig,
|
||||
});
|
||||
// Send reloadInitiated message to iframe so it can track it
|
||||
// Send it after loadInIframe so that new iframe can process it.
|
||||
// Send it after loadInIframe so that new iframe can process it.
|
||||
this.cal.doInIframe({ method: "__reloadInitiated", arg: {} });
|
||||
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
|
||||
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
|
||||
@@ -1196,8 +1205,8 @@ class CalApi {
|
||||
...enrichedConfig,
|
||||
...(actionToTake === "connect-no-slots-fetch"
|
||||
? {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
params: paramsToAdd,
|
||||
@@ -1239,10 +1248,10 @@ class CalApi {
|
||||
|
||||
template.innerHTML = `<cal-modal-box
|
||||
${generateDataAttributes({
|
||||
pageType,
|
||||
theme,
|
||||
layout,
|
||||
})}
|
||||
pageType,
|
||||
theme,
|
||||
layout,
|
||||
})}
|
||||
uid="${uid}">
|
||||
</cal-modal-box>`;
|
||||
this.cal.modalBox = template.content.children[0];
|
||||
@@ -1303,9 +1312,9 @@ class CalApi {
|
||||
|
||||
/**
|
||||
* Closes modal-based embeds programmatically.
|
||||
*
|
||||
*
|
||||
* @throws {Error} If called on an inline embed (only works for modal-based embeds)
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* // Close the modal after a successful booking
|
||||
@@ -1487,6 +1496,7 @@ class CalApi {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
this.cal.doInIframe({ method: "ui", arg: uiConfig });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ The booking audit system uses two complementary fields:
|
||||
- **API_V1**: API v1 endpoint
|
||||
- **API_V2**: API v2 endpoint
|
||||
- **WEBHOOK**: External webhook (e.g., Stripe)
|
||||
- **SYSTEM**: Background job (e.g., Tasker's task, trigger.dev job for automatic no-show detection)
|
||||
- **UNKNOWN**: Source cannot be determined
|
||||
|
||||
### Actor: The Entity
|
||||
@@ -75,8 +76,7 @@ The system tracks various booking actions including:
|
||||
- **ATTENDEE_REMOVED**: Attendee removed
|
||||
- **REASSIGNMENT**: Booking reassigned to different host
|
||||
- **LOCATION_CHANGED**: Meeting location updated
|
||||
- **HOST_NO_SHOW_UPDATED**: Host no-show status changed
|
||||
- **ATTENDEE_NO_SHOW_UPDATED**: Attendee no-show status changed
|
||||
- **NO_SHOW_UPDATED**: Host or attendee no-show status changed
|
||||
- **SEAT_BOOKED**: Seat reserved in group booking
|
||||
- **SEAT_RESCHEDULED**: Seat rescheduled in group booking
|
||||
|
||||
@@ -204,7 +204,7 @@ The audit system works with third-party queue providers without exposing PII:
|
||||
- `operationId`: Required string for correlating related audit logs
|
||||
- `data`: Action-specific data
|
||||
- `timestamp`: Number (milliseconds since epoch)
|
||||
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN)
|
||||
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, SYSTEM, UNKNOWN)
|
||||
|
||||
**BookingAuditTaskConsumer** processes audit records:
|
||||
- Validates queue payload structure
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/ser
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
|
||||
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
|
||||
import { moduleLoader as attendeeRepositoryModuleLoader } from "@calcom/features/bookings/di/Attendee.module";
|
||||
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
|
||||
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
|
||||
|
||||
@@ -20,6 +21,7 @@ const loadModule = bindModuleToClassOnToken({
|
||||
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
|
||||
auditActorRepository: auditActorRepositoryModuleLoader,
|
||||
featuresRepository: featuresRepositoryModuleLoader,
|
||||
attendeeRepository: attendeeRepositoryModuleLoader,
|
||||
userRepository: userRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee No-Show Updated Audit Action Service
|
||||
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowAttendee: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson({
|
||||
storedData,
|
||||
}: GetDisplayJsonParams): AttendeeNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
return {
|
||||
noShowAttendee: fields.noShowAttendee.new,
|
||||
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditDisplayData = {
|
||||
noShowAttendee: boolean;
|
||||
previousNoShowAttendee: boolean | null;
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Host No-Show Updated Audit Action Service
|
||||
* Handles HOST_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowHost: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class HostNoShowUpdatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof HostNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.host_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson({
|
||||
storedData,
|
||||
}: GetDisplayJsonParams): HostNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
return {
|
||||
noShowHost: fields.noShowHost.new,
|
||||
previousNoShowHost: fields.noShowHost.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type HostNoShowUpdatedAuditDisplayData = {
|
||||
noShowHost: boolean;
|
||||
previousNoShowHost: boolean | null;
|
||||
};
|
||||
@@ -3,8 +3,8 @@
|
||||
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
|
||||
*/
|
||||
export type TranslationComponent = {
|
||||
type: "link";
|
||||
href: string;
|
||||
type: "link";
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -13,117 +13,126 @@ export type TranslationComponent = {
|
||||
* Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking</1>")
|
||||
*/
|
||||
export type TranslationWithParams = {
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* This is agnostic of the action and is common for all actions
|
||||
*/
|
||||
export type BaseStoredAuditData = {
|
||||
version: number;
|
||||
fields: Record<string, unknown>;
|
||||
version: number;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GetDisplayJsonParams = {
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
};
|
||||
|
||||
export type GetDisplayTitleParams = {
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
};
|
||||
|
||||
export type GetDisplayFieldsParams = {
|
||||
storedData: BaseStoredAuditData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for Audit Action Services
|
||||
*
|
||||
*
|
||||
* Defines the contract that all audit action services must implement.
|
||||
* Uses composition with AuditActionServiceHelper to provide common functionality
|
||||
* while maintaining runtime type safety through Zod schema validation.
|
||||
*
|
||||
*
|
||||
* All methods use common base types at the interface level, while implementations
|
||||
* validate to their specific schemas internally.
|
||||
*/
|
||||
export interface IAuditActionService {
|
||||
/**
|
||||
* Current version number for this action type
|
||||
*/
|
||||
readonly VERSION: number;
|
||||
/**
|
||||
* Current version number for this action type
|
||||
*/
|
||||
readonly VERSION: number;
|
||||
|
||||
/**
|
||||
* Parse given fields against latest schema and wrap with version
|
||||
* @param fields - Raw input fields (just the audit fields)
|
||||
* @returns Parsed data with version wrapper { version, fields }
|
||||
*/
|
||||
getVersionedData(fields: unknown): BaseStoredAuditData;
|
||||
/**
|
||||
* Parse given fields against latest schema and wrap with version
|
||||
* @param fields - Raw input fields (just the audit fields)
|
||||
* @returns Parsed data with version wrapper { version, fields }
|
||||
*/
|
||||
getVersionedData(fields: unknown): BaseStoredAuditData;
|
||||
|
||||
/**
|
||||
* Parse stored audit record (includes version wrapper)
|
||||
* Accepts data from ANY supported version (not just latest) for backward compatibility
|
||||
* @param data - Stored data from database (can be any version)
|
||||
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
|
||||
*/
|
||||
parseStored(data: unknown): BaseStoredAuditData;
|
||||
/**
|
||||
* Parse stored audit record (includes version wrapper)
|
||||
* Accepts data from ANY supported version (not just latest) for backward compatibility
|
||||
* @param data - Stored data from database (can be any version)
|
||||
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
|
||||
*/
|
||||
parseStored(data: unknown): BaseStoredAuditData;
|
||||
|
||||
/**
|
||||
* Extract version number from stored data
|
||||
* @param data - Stored data from database
|
||||
* @returns Version number
|
||||
*/
|
||||
getVersion(data: unknown): number;
|
||||
/**
|
||||
* Extract version number from stored data
|
||||
* @param data - Stored data from database
|
||||
* @returns Version number
|
||||
*/
|
||||
getVersion(data: unknown): number;
|
||||
|
||||
/**
|
||||
* Get flattened JSON data for display (fields only, no version wrapper)
|
||||
* Optional - implement only if custom display formatting is needed
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for datetime formatting (required)
|
||||
* @returns The fields object without version wrapper and we decide what fields to show to the client
|
||||
*/
|
||||
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
|
||||
/**
|
||||
* Get flattened JSON data for display (fields only, no version wrapper)
|
||||
* Optional - implement only if custom display formatting is needed
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for datetime formatting (required)
|
||||
* @returns The fields object without version wrapper and we decide what fields to show to the client
|
||||
*/
|
||||
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Get the display title for the audit action
|
||||
* Returns a translation key with optional interpolation params for dynamic titles
|
||||
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for date formatting (required)
|
||||
* @returns Translation key with optional interpolation params
|
||||
*/
|
||||
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
|
||||
/**
|
||||
* Get the display title for the audit action
|
||||
* Returns a translation key with optional interpolation params for dynamic titles
|
||||
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for date formatting (required)
|
||||
* @returns Translation key with optional interpolation params
|
||||
*/
|
||||
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
|
||||
|
||||
/**
|
||||
* Returns additional display fields with translation keys for frontend rendering
|
||||
* Optional - implement only if custom display fields are needed
|
||||
* @param storedData - Parsed stored data { version, fields }
|
||||
* @returns Promise of array of field objects with label and value translation keys
|
||||
*/
|
||||
getDisplayFields?(storedData: BaseStoredAuditData): Promise<Array<{
|
||||
labelKey: string; // Translation key for field label
|
||||
valueKey: string; // Translation key for field value
|
||||
}>>;
|
||||
/**
|
||||
* Returns additional display fields with translation keys for frontend rendering
|
||||
* Optional - implement only if custom display fields are needed
|
||||
* @param params - Object containing storedData
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @returns Promise of array of field objects with label and value (either translation key or raw value)
|
||||
*/
|
||||
getDisplayFields?(params: GetDisplayFieldsParams): Promise<
|
||||
Array<{
|
||||
labelKey: string; // Translation key for field label
|
||||
valueKey?: string; // Translation key for field value (will be translated)
|
||||
value?: string; // Raw value for field value (will NOT be translated)
|
||||
values?: string[]; // Array of raw values (will NOT be translated, rendered as separate lines)
|
||||
}>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Migrate old version data to latest version
|
||||
*
|
||||
* Required method that validates and migrates data to latest schema version.
|
||||
* For V1-only actions, simply validates and returns with isMigrated=false.
|
||||
* For multi-version actions, checks version and transforms if needed.
|
||||
*
|
||||
* @param data - Data from task payload (any supported version)
|
||||
* @returns Migration result with status and latest data
|
||||
*/
|
||||
migrateToLatest(data: unknown): {
|
||||
/**
|
||||
* Migrate old version data to latest version
|
||||
*
|
||||
* Required method that validates and migrates data to latest schema version.
|
||||
* For V1-only actions, simply validates and returns with isMigrated=false.
|
||||
* For multi-version actions, checks version and transforms if needed.
|
||||
*
|
||||
* @param data - Data from task payload (any supported version)
|
||||
* @returns Migration result with status and latest data
|
||||
* True if migration was performed, false if already latest
|
||||
*/
|
||||
migrateToLatest(data: unknown): {
|
||||
/**
|
||||
* True if migration was performed, false if already latest
|
||||
*/
|
||||
isMigrated: boolean;
|
||||
/**
|
||||
* Always set, either migrated or original
|
||||
*/
|
||||
latestData: Record<string, unknown>;
|
||||
};
|
||||
isMigrated: boolean;
|
||||
/**
|
||||
* Always set, either migrated or original
|
||||
*/
|
||||
latestData: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import { z } from "zod";
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type {
|
||||
GetDisplayFieldsParams,
|
||||
GetDisplayJsonParams,
|
||||
GetDisplayTitleParams,
|
||||
IAuditActionService,
|
||||
TranslationWithParams,
|
||||
} from "./IAuditActionService";
|
||||
|
||||
const AttendeeNoShowSchema = z.object({
|
||||
attendeeEmail: z.string(),
|
||||
noShow: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
const fieldsSchemaV1 = z
|
||||
.object({
|
||||
host: z
|
||||
.object({
|
||||
userUuid: z.string(),
|
||||
noShow: BooleanChangeSchema,
|
||||
})
|
||||
.optional(),
|
||||
attendeesNoShow: z.array(AttendeeNoShowSchema).optional(),
|
||||
})
|
||||
.refine((data) => data.host !== undefined || data.attendeesNoShow !== undefined, {
|
||||
message: "At least one of host or attendeesNoShow must be provided",
|
||||
});
|
||||
|
||||
type Deps = {
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
userRepository: UserRepository;
|
||||
};
|
||||
|
||||
export class NoShowUpdatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = NoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = NoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof NoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof NoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor(private readonly deps: Deps) {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: NoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: NoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
private isHostSet(fields: NoShowUpdatedAuditData): fields is Ensure<NoShowUpdatedAuditData, "host"> {
|
||||
return fields.host !== undefined;
|
||||
}
|
||||
|
||||
private isAttendeesNoShowSet(
|
||||
fields: NoShowUpdatedAuditData
|
||||
): fields is Ensure<NoShowUpdatedAuditData, "attendeesNoShow"> {
|
||||
return fields.attendeesNoShow !== undefined;
|
||||
}
|
||||
|
||||
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
const parsedFields = fields as NoShowUpdatedAuditData;
|
||||
if (this.isHostSet(parsedFields) && this.isAttendeesNoShowSet(parsedFields)) {
|
||||
return { key: "booking_audit_action.no_show_updated" };
|
||||
}
|
||||
if (this.isHostSet(parsedFields)) {
|
||||
return { key: "booking_audit_action.host_no_show_updated" };
|
||||
}
|
||||
if (this.isAttendeesNoShowSet(parsedFields)) {
|
||||
return { key: "booking_audit_action.attendee_no_show_updated" };
|
||||
}
|
||||
throw new Error("Audit action data is invalid");
|
||||
}
|
||||
|
||||
async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise<
|
||||
Array<{
|
||||
labelKey: string;
|
||||
valueKey?: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
}>
|
||||
> {
|
||||
const { fields } = this.parseStored(storedData);
|
||||
const parsedFields = fields as NoShowUpdatedAuditData;
|
||||
const displayFields: { labelKey: string; valueKey?: string; value?: string; values?: string[] }[] = [];
|
||||
|
||||
if (this.isAttendeesNoShowSet(parsedFields)) {
|
||||
const attendeesFieldValues = parsedFields.attendeesNoShow.map((attendee) => {
|
||||
const noShowStatus = attendee.noShow.new ? "Yes" : "No";
|
||||
return `${attendee.attendeeEmail}: ${noShowStatus}`;
|
||||
});
|
||||
displayFields.push({ labelKey: "Attendees", values: attendeesFieldValues });
|
||||
}
|
||||
|
||||
if (this.isHostSet(parsedFields)) {
|
||||
const user = await this.deps.userRepository.findByUuid({ uuid: parsedFields.host.userUuid });
|
||||
const hostName = user?.name || "Unknown";
|
||||
const hostFieldValue = `${hostName}:${parsedFields.host.noShow.new ? "Yes" : "No"}`;
|
||||
displayFields.push({ labelKey: "Host", value: hostFieldValue });
|
||||
}
|
||||
|
||||
return displayFields;
|
||||
}
|
||||
|
||||
getDisplayJson({ storedData }: GetDisplayJsonParams): NoShowUpdatedAuditDisplayData {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
const parsedFields = fields as NoShowUpdatedAuditData;
|
||||
return {
|
||||
...(this.isHostSet(parsedFields)
|
||||
? {
|
||||
hostNoShow: parsedFields.host.noShow.new,
|
||||
previousHostNoShow: parsedFields.host.noShow.old,
|
||||
}
|
||||
: {}),
|
||||
...(this.isAttendeesNoShowSet(parsedFields)
|
||||
? {
|
||||
attendeesNoShow: parsedFields.attendeesNoShow,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type NoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type NoShowUpdatedAuditDisplayData = {
|
||||
hostNoShow?: boolean;
|
||||
previousHostNoShow?: boolean | null;
|
||||
attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }> | null;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type {
|
||||
BaseStoredAuditData,
|
||||
GetDisplayFieldsParams,
|
||||
GetDisplayJsonParams,
|
||||
GetDisplayTitleParams,
|
||||
IAuditActionService,
|
||||
@@ -123,7 +123,7 @@ export class ReassignmentAuditActionService implements IAuditActionService {
|
||||
};
|
||||
}
|
||||
|
||||
async getDisplayFields(storedData: BaseStoredAuditData): Promise<
|
||||
async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise<
|
||||
Array<{
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
|
||||
+2
-2
@@ -279,7 +279,7 @@ describe("ReassignmentAuditActionService", () => {
|
||||
|
||||
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
|
||||
|
||||
const result = await service.getDisplayFields(storedData);
|
||||
const result = await service.getDisplayFields({ storedData });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -305,7 +305,7 @@ describe("ReassignmentAuditActionService", () => {
|
||||
|
||||
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
|
||||
|
||||
const result = await service.getDisplayFields(storedData);
|
||||
const result = await service.getDisplayFields({ storedData });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DEL
|
||||
* They are reserved in the enum for potential future use but should not appear in audit logs.
|
||||
* Use the CREATED action to capture initial booking status instead.
|
||||
*/
|
||||
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
|
||||
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
|
||||
export type BookingAuditCreateInput = {
|
||||
bookingUid: string;
|
||||
actorId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { IAuditActionService } from "../actions/IAuditActionService";
|
||||
import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
|
||||
@@ -8,12 +9,11 @@ import { RescheduledAuditActionService, type RescheduledAuditData } from "../act
|
||||
import { AcceptedAuditActionService, type AcceptedAuditData } from "../actions/AcceptedAuditActionService";
|
||||
import { RescheduleRequestedAuditActionService, type RescheduleRequestedAuditData } from "../actions/RescheduleRequestedAuditActionService";
|
||||
import { AttendeeAddedAuditActionService, type AttendeeAddedAuditData } from "../actions/AttendeeAddedAuditActionService";
|
||||
import { HostNoShowUpdatedAuditActionService, type HostNoShowUpdatedAuditData } from "../actions/HostNoShowUpdatedAuditActionService";
|
||||
import { NoShowUpdatedAuditActionService, type NoShowUpdatedAuditData } from "../actions/NoShowUpdatedAuditActionService";
|
||||
import { RejectedAuditActionService, type RejectedAuditData } from "../actions/RejectedAuditActionService";
|
||||
import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from "../actions/AttendeeRemovedAuditActionService";
|
||||
import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService";
|
||||
import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService";
|
||||
import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService";
|
||||
import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService";
|
||||
|
||||
@@ -28,12 +28,11 @@ export type AuditActionData =
|
||||
| AcceptedAuditData
|
||||
| RescheduleRequestedAuditData
|
||||
| AttendeeAddedAuditData
|
||||
| HostNoShowUpdatedAuditData
|
||||
| NoShowUpdatedAuditData
|
||||
| RejectedAuditData
|
||||
| AttendeeRemovedAuditData
|
||||
| ReassignmentAuditData
|
||||
| LocationChangedAuditData
|
||||
| AttendeeNoShowUpdatedAuditData
|
||||
| SeatBookedAuditData
|
||||
| SeatRescheduledAuditData;
|
||||
|
||||
@@ -45,6 +44,7 @@ export type AuditActionData =
|
||||
* code duplication between consumer and viewer services.
|
||||
*/
|
||||
interface BookingAuditActionServiceRegistryDeps {
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
userRepository: UserRepository;
|
||||
}
|
||||
|
||||
@@ -59,12 +59,11 @@ export class BookingAuditActionServiceRegistry {
|
||||
["ACCEPTED", new AcceptedAuditActionService()],
|
||||
["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()],
|
||||
["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()],
|
||||
["HOST_NO_SHOW_UPDATED", new HostNoShowUpdatedAuditActionService()],
|
||||
["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService({ attendeeRepository: deps.attendeeRepository, userRepository: deps.userRepository })],
|
||||
["REJECTED", new RejectedAuditActionService()],
|
||||
["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()],
|
||||
["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)],
|
||||
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
|
||||
["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()],
|
||||
["SEAT_BOOKED", new SeatBookedAuditActionService()],
|
||||
["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()],
|
||||
];
|
||||
|
||||
+3
-14
@@ -4,12 +4,11 @@ import type { Actor, BookingAuditContext } from "../dto/types";
|
||||
import type { ActionSource } from "../types/actionSource";
|
||||
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
|
||||
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
|
||||
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
|
||||
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
|
||||
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
|
||||
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
|
||||
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
|
||||
import { NoShowUpdatedAuditActionService } from "../actions/NoShowUpdatedAuditActionService";
|
||||
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
|
||||
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
|
||||
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
|
||||
@@ -91,13 +90,13 @@ export interface BookingAuditProducerService {
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueHostNoShowUpdatedAudit(params: {
|
||||
queueNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
@@ -141,16 +140,6 @@ export interface BookingAuditProducerService {
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueAttendeeNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueSeatBookedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JsonValue } from "@calcom/types/Json";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
|
||||
@@ -21,6 +22,7 @@ interface BookingAuditTaskConsumerDeps {
|
||||
bookingAuditRepository: IBookingAuditRepository;
|
||||
auditActorRepository: IAuditActorRepository;
|
||||
featuresRepository: IFeaturesRepository;
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
userRepository: UserRepository;
|
||||
}
|
||||
|
||||
@@ -69,16 +71,21 @@ export class BookingAuditTaskConsumer {
|
||||
private readonly bookingAuditRepository: IBookingAuditRepository;
|
||||
private readonly auditActorRepository: IAuditActorRepository;
|
||||
private readonly featuresRepository: IFeaturesRepository;
|
||||
private readonly attendeeRepository: IAttendeeRepository;
|
||||
private readonly userRepository: UserRepository;
|
||||
|
||||
constructor(private readonly deps: BookingAuditTaskConsumerDeps) {
|
||||
this.bookingAuditRepository = deps.bookingAuditRepository;
|
||||
this.auditActorRepository = deps.auditActorRepository;
|
||||
this.featuresRepository = deps.featuresRepository;
|
||||
this.attendeeRepository = deps.attendeeRepository;
|
||||
this.userRepository = deps.userRepository;
|
||||
|
||||
// Centralized registry for all action services
|
||||
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
|
||||
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({
|
||||
attendeeRepository: this.attendeeRepository,
|
||||
userRepository: this.userRepository,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,8 +373,7 @@ export class BookingAuditTaskConsumer {
|
||||
case "ATTENDEE_REMOVED":
|
||||
case "REASSIGNMENT":
|
||||
case "LOCATION_CHANGED":
|
||||
case "HOST_NO_SHOW_UPDATED":
|
||||
case "ATTENDEE_NO_SHOW_UPDATED":
|
||||
case "NO_SHOW_UPDATED":
|
||||
case "SEAT_BOOKED":
|
||||
case "SEAT_RESCHEDULED":
|
||||
return "RECORD_UPDATED";
|
||||
|
||||
@@ -12,12 +12,11 @@ import { makeActorById, buildActorEmail } from "../makeActor";
|
||||
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
|
||||
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
|
||||
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
|
||||
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
|
||||
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
|
||||
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
|
||||
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
|
||||
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
|
||||
import { NoShowUpdatedAuditActionService } from "../actions/NoShowUpdatedAuditActionService";
|
||||
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
|
||||
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
|
||||
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
|
||||
@@ -216,17 +215,18 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
|
||||
});
|
||||
}
|
||||
|
||||
async queueHostNoShowUpdatedAudit(params: {
|
||||
async queueNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void> {
|
||||
await this.queueTask({
|
||||
...params,
|
||||
action: HostNoShowUpdatedAuditActionService.TYPE,
|
||||
action: NoShowUpdatedAuditActionService.TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -286,20 +286,6 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
|
||||
});
|
||||
}
|
||||
|
||||
async queueAttendeeNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
}): Promise<void> {
|
||||
await this.queueTask({
|
||||
...params,
|
||||
action: AttendeeNoShowUpdatedAuditActionService.TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
async queueSeatBookedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
||||
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
|
||||
import { BookingAuditAccessService } from "./BookingAuditAccessService";
|
||||
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository";
|
||||
import type { AuditActorType } from "../repository/IAuditActorRepository";
|
||||
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
||||
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { TranslationWithParams } from "../actions/IAuditActionService";
|
||||
import type { ActionSource } from "../types/actionSource";
|
||||
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
|
||||
import { getAppNameFromSlug } from "../getAppNameFromSlug";
|
||||
import type { BookingAuditContext } from "../dto/types";
|
||||
import { getAppNameFromSlug } from "../getAppNameFromSlug";
|
||||
import type { AuditActorType } from "../repository/IAuditActorRepository";
|
||||
import type {
|
||||
BookingAuditAction,
|
||||
BookingAuditType,
|
||||
BookingAuditWithActor,
|
||||
IBookingAuditRepository,
|
||||
} from "../repository/IBookingAuditRepository";
|
||||
import type { ActionSource } from "../types/actionSource";
|
||||
import { BookingAuditAccessService } from "./BookingAuditAccessService";
|
||||
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
|
||||
|
||||
interface BookingAuditViewerServiceDeps {
|
||||
bookingAuditRepository: IBookingAuditRepository;
|
||||
userRepository: UserRepository;
|
||||
bookingRepository: BookingRepository;
|
||||
membershipRepository: MembershipRepository;
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
log: ISimpleLogger;
|
||||
credentialRepository: CredentialRepository;
|
||||
bookingAuditRepository: IBookingAuditRepository;
|
||||
userRepository: UserRepository;
|
||||
bookingRepository: BookingRepository;
|
||||
membershipRepository: MembershipRepository;
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
log: ISimpleLogger;
|
||||
credentialRepository: CredentialRepository;
|
||||
}
|
||||
|
||||
type EnrichedAuditLog = {
|
||||
id: string;
|
||||
bookingUid: string;
|
||||
type: BookingAuditType;
|
||||
action: BookingAuditAction;
|
||||
timestamp: string;
|
||||
createdAt: string;
|
||||
source: ActionSource;
|
||||
operationId: string;
|
||||
displayJson?: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
|
||||
actor: {
|
||||
id: string;
|
||||
bookingUid: string;
|
||||
type: BookingAuditType;
|
||||
action: BookingAuditAction;
|
||||
timestamp: string;
|
||||
createdAt: string;
|
||||
source: ActionSource;
|
||||
operationId: string;
|
||||
displayJson?: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
|
||||
actor: {
|
||||
id: string;
|
||||
type: AuditActorType;
|
||||
userUuid: string | null;
|
||||
attendeeId: number | null;
|
||||
name: string | null;
|
||||
createdAt: Date;
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
};
|
||||
impersonatedBy?: {
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null;
|
||||
hasError?: boolean;
|
||||
type: AuditActorType;
|
||||
userUuid: string | null;
|
||||
attendeeId: number | null;
|
||||
name: string | null;
|
||||
createdAt: Date;
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
};
|
||||
impersonatedBy?: {
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
export type DisplayBookingAuditLog = EnrichedAuditLog;
|
||||
@@ -61,337 +66,346 @@ export type DisplayBookingAuditLog = EnrichedAuditLog;
|
||||
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
|
||||
*/
|
||||
export class BookingAuditViewerService {
|
||||
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
|
||||
private readonly bookingAuditRepository: IBookingAuditRepository;
|
||||
private readonly userRepository: UserRepository;
|
||||
private readonly bookingRepository: BookingRepository;
|
||||
private readonly membershipRepository: MembershipRepository;
|
||||
private readonly attendeeRepository: IAttendeeRepository;
|
||||
private readonly credentialRepository: CredentialRepository;
|
||||
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
|
||||
private readonly accessService: BookingAuditAccessService;
|
||||
private readonly log: BookingAuditViewerServiceDeps["log"];
|
||||
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
|
||||
private readonly bookingAuditRepository: IBookingAuditRepository;
|
||||
private readonly userRepository: UserRepository;
|
||||
private readonly bookingRepository: BookingRepository;
|
||||
private readonly membershipRepository: MembershipRepository;
|
||||
private readonly attendeeRepository: IAttendeeRepository;
|
||||
private readonly credentialRepository: CredentialRepository;
|
||||
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
|
||||
private readonly accessService: BookingAuditAccessService;
|
||||
private readonly log: BookingAuditViewerServiceDeps["log"];
|
||||
|
||||
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
|
||||
this.bookingAuditRepository = deps.bookingAuditRepository;
|
||||
this.userRepository = deps.userRepository;
|
||||
this.bookingRepository = deps.bookingRepository;
|
||||
this.membershipRepository = deps.membershipRepository;
|
||||
this.attendeeRepository = deps.attendeeRepository;
|
||||
this.credentialRepository = deps.credentialRepository;
|
||||
this.log = deps.log;
|
||||
this.rescheduledAuditActionService = new RescheduledAuditActionService();
|
||||
this.accessService = new BookingAuditAccessService({
|
||||
bookingRepository: this.bookingRepository,
|
||||
membershipRepository: this.membershipRepository,
|
||||
});
|
||||
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
|
||||
}
|
||||
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
|
||||
this.bookingAuditRepository = deps.bookingAuditRepository;
|
||||
this.userRepository = deps.userRepository;
|
||||
this.bookingRepository = deps.bookingRepository;
|
||||
this.membershipRepository = deps.membershipRepository;
|
||||
this.attendeeRepository = deps.attendeeRepository;
|
||||
this.credentialRepository = deps.credentialRepository;
|
||||
this.log = deps.log;
|
||||
this.rescheduledAuditActionService = new RescheduledAuditActionService();
|
||||
this.accessService = new BookingAuditAccessService({
|
||||
bookingRepository: this.bookingRepository,
|
||||
membershipRepository: this.membershipRepository,
|
||||
});
|
||||
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({
|
||||
attendeeRepository: this.attendeeRepository,
|
||||
userRepository: this.userRepository,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a booking with full enrichment and formatting
|
||||
* Handles permission checks, fetches logs, enriches actors, and formats display
|
||||
*
|
||||
* For bookings created from a reschedule (has fromReschedule field), this also
|
||||
* fetches the last RESCHEDULED log from the previous booking and includes it
|
||||
* as the first log entry with "rescheduled from" context.
|
||||
*/
|
||||
async getAuditLogsForBooking(params: {
|
||||
bookingUid: string;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
userTimeZone: string;
|
||||
organizationId: number | null;
|
||||
}): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> {
|
||||
const { bookingUid, userId, userTimeZone, organizationId } = params;
|
||||
await this.accessService.assertPermissions({
|
||||
bookingUid,
|
||||
userId,
|
||||
organizationId
|
||||
});
|
||||
/**
|
||||
* Get audit logs for a booking with full enrichment and formatting
|
||||
* Handles permission checks, fetches logs, enriches actors, and formats display
|
||||
*
|
||||
* For bookings created from a reschedule (has fromReschedule field), this also
|
||||
* fetches the last RESCHEDULED log from the previous booking and includes it
|
||||
* as the first log entry with "rescheduled from" context.
|
||||
*/
|
||||
async getAuditLogsForBooking(params: {
|
||||
bookingUid: string;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
userTimeZone: string;
|
||||
organizationId: number | null;
|
||||
}): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> {
|
||||
const { bookingUid, userId, userTimeZone, organizationId } = params;
|
||||
await this.accessService.assertPermissions({
|
||||
bookingUid,
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
|
||||
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
|
||||
|
||||
const enrichedAuditLogs = await Promise.all(
|
||||
auditLogs.map(async (log) => {
|
||||
try {
|
||||
return await this.enrichAuditLog(log, userTimeZone);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return this.buildFallbackAuditLog(log);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
|
||||
|
||||
// Check if this booking was created from a reschedule
|
||||
if (fromRescheduleUid) {
|
||||
const rescheduledFromLog = await this.buildRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
currentBookingUid: bookingUid,
|
||||
userTimeZone,
|
||||
});
|
||||
if (rescheduledFromLog) {
|
||||
enrichedAuditLogs.push(rescheduledFromLog);
|
||||
}
|
||||
const enrichedAuditLogs = await Promise.all(
|
||||
auditLogs.map(async (log) => {
|
||||
try {
|
||||
return await this.enrichAuditLog(log, userTimeZone);
|
||||
} catch (error) {
|
||||
this.log.error(
|
||||
`Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return this.buildFallbackAuditLog(log);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
bookingUid: params.bookingUid,
|
||||
auditLogs: enrichedAuditLogs,
|
||||
};
|
||||
}
|
||||
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
|
||||
|
||||
/**
|
||||
* Enriches a single audit log with actor information and formatted display data
|
||||
*/
|
||||
private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise<EnrichedAuditLog> {
|
||||
const enrichedActor = await this.enrichActorInformation(log.actor);
|
||||
|
||||
const actionService = this.actionServiceRegistry.getActionService(log.action);
|
||||
const parsedData = actionService.parseStored(log.data);
|
||||
|
||||
const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone });
|
||||
|
||||
const displayJson = actionService.getDisplayJson
|
||||
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
|
||||
: null;
|
||||
|
||||
const displayFields = actionService.getDisplayFields
|
||||
? await actionService.getDisplayFields(parsedData)
|
||||
: null;
|
||||
|
||||
const impersonatedBy = await this.enrichImpersonator(log.context);
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
bookingUid: log.bookingUid,
|
||||
type: log.type,
|
||||
action: log.action,
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
source: log.source,
|
||||
operationId: log.operationId,
|
||||
displayJson,
|
||||
actionDisplayTitle,
|
||||
displayFields,
|
||||
actor: {
|
||||
id: log.actor.id,
|
||||
type: log.actor.type,
|
||||
userUuid: log.actor.userUuid,
|
||||
attendeeId: log.actor.attendeeId,
|
||||
name: log.actor.name,
|
||||
createdAt: log.actor.createdAt,
|
||||
displayName: enrichedActor.displayName,
|
||||
displayEmail: enrichedActor.displayEmail,
|
||||
displayAvatar: enrichedActor.displayAvatar,
|
||||
},
|
||||
impersonatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal fallback audit log when enrichment fails.
|
||||
* Returns a log entry with hasError: true and basic information from the raw log.
|
||||
*/
|
||||
private buildFallbackAuditLog(log: BookingAuditWithActor): EnrichedAuditLog {
|
||||
return {
|
||||
id: log.id,
|
||||
bookingUid: log.bookingUid,
|
||||
type: log.type,
|
||||
action: log.action,
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
source: log.source,
|
||||
operationId: log.operationId,
|
||||
displayJson: null,
|
||||
actionDisplayTitle: { key: "booking_audit_action.error_processing", params: { actionType: log.action } },
|
||||
displayFields: null,
|
||||
actor: {
|
||||
id: log.actor.id,
|
||||
type: log.actor.type,
|
||||
userUuid: log.actor.userUuid,
|
||||
attendeeId: log.actor.attendeeId,
|
||||
name: log.actor.name,
|
||||
createdAt: log.actor.createdAt,
|
||||
displayName: log.actor.name || "Unknown",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
},
|
||||
impersonatedBy: null,
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
|
||||
* Fetches the RESCHEDULED log from the previous booking and transforms it
|
||||
* to show "rescheduled from" context for the current booking.
|
||||
*/
|
||||
private async buildRescheduledFromLog({
|
||||
// Check if this booking was created from a reschedule
|
||||
if (fromRescheduleUid) {
|
||||
const rescheduledFromLog = await this.buildRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
currentBookingUid,
|
||||
currentBookingUid: bookingUid,
|
||||
userTimeZone,
|
||||
}: {
|
||||
fromRescheduleUid: string;
|
||||
currentBookingUid: string;
|
||||
userTimeZone: string;
|
||||
}): Promise<EnrichedAuditLog | null> {
|
||||
const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(
|
||||
fromRescheduleUid
|
||||
);
|
||||
|
||||
// Find the specific log that created this booking by matching rescheduledToUid
|
||||
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
|
||||
rescheduledLogs,
|
||||
rescheduledToBookingUid: currentBookingUid,
|
||||
});
|
||||
|
||||
if (!rescheduledLog) {
|
||||
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
|
||||
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
|
||||
return null;
|
||||
}
|
||||
|
||||
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
|
||||
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
|
||||
|
||||
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
|
||||
// by replacing rescheduledToUid with rescheduledFromUid
|
||||
const transformedDisplayJson = enrichedLog.displayJson
|
||||
? {
|
||||
...enrichedLog.displayJson,
|
||||
rescheduledFromUid: fromRescheduleUid,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...enrichedLog,
|
||||
// Override bookingUid to associate with the current booking being viewed
|
||||
bookingUid: currentBookingUid,
|
||||
displayJson: transformedDisplayJson,
|
||||
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
|
||||
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
userTimeZone,
|
||||
storedData: parsedData,
|
||||
}),
|
||||
};
|
||||
});
|
||||
if (rescheduledFromLog) {
|
||||
enrichedAuditLogs.push(rescheduledFromLog);
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichImpersonator(context: BookingAuditContext | null): Promise<{
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null> {
|
||||
if (!context?.impersonatedBy) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bookingUid: params.bookingUid,
|
||||
auditLogs: enrichedAuditLogs,
|
||||
};
|
||||
}
|
||||
|
||||
const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy });
|
||||
if (!impersonatorUser) {
|
||||
/**
|
||||
* Enriches a single audit log with actor information and formatted display data
|
||||
*/
|
||||
private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise<EnrichedAuditLog> {
|
||||
const enrichedActor = await this.enrichActorInformation(log.actor);
|
||||
|
||||
const actionService = this.actionServiceRegistry.getActionService(log.action);
|
||||
const parsedData = actionService.parseStored(log.data);
|
||||
|
||||
const actionDisplayTitle = await actionService.getDisplayTitle({
|
||||
storedData: parsedData,
|
||||
userTimeZone,
|
||||
});
|
||||
|
||||
const displayJson = actionService.getDisplayJson
|
||||
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
|
||||
: null;
|
||||
|
||||
const displayFields = actionService.getDisplayFields
|
||||
? await actionService.getDisplayFields({ storedData: parsedData })
|
||||
: null;
|
||||
|
||||
const impersonatedBy = await this.enrichImpersonator(log.context);
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
bookingUid: log.bookingUid,
|
||||
type: log.type,
|
||||
action: log.action,
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
source: log.source,
|
||||
operationId: log.operationId,
|
||||
displayJson,
|
||||
actionDisplayTitle,
|
||||
displayFields,
|
||||
actor: {
|
||||
id: log.actor.id,
|
||||
type: log.actor.type,
|
||||
userUuid: log.actor.userUuid,
|
||||
attendeeId: log.actor.attendeeId,
|
||||
name: log.actor.name,
|
||||
createdAt: log.actor.createdAt,
|
||||
displayName: enrichedActor.displayName,
|
||||
displayEmail: enrichedActor.displayEmail,
|
||||
displayAvatar: enrichedActor.displayAvatar,
|
||||
},
|
||||
impersonatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal fallback audit log when enrichment fails.
|
||||
* Returns a log entry with hasError: true and basic information from the raw log.
|
||||
*/
|
||||
private buildFallbackAuditLog(log: BookingAuditWithActor): EnrichedAuditLog {
|
||||
return {
|
||||
id: log.id,
|
||||
bookingUid: log.bookingUid,
|
||||
type: log.type,
|
||||
action: log.action,
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
source: log.source,
|
||||
operationId: log.operationId,
|
||||
displayJson: null,
|
||||
actionDisplayTitle: {
|
||||
key: "booking_audit_action.error_processing",
|
||||
params: { actionType: log.action },
|
||||
},
|
||||
displayFields: null,
|
||||
actor: {
|
||||
id: log.actor.id,
|
||||
type: log.actor.type,
|
||||
userUuid: log.actor.userUuid,
|
||||
attendeeId: log.actor.attendeeId,
|
||||
name: log.actor.name,
|
||||
createdAt: log.actor.createdAt,
|
||||
displayName: log.actor.name || "Unknown",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
},
|
||||
impersonatedBy: null,
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
|
||||
* Fetches the RESCHEDULED log from the previous booking and transforms it
|
||||
* to show "rescheduled from" context for the current booking.
|
||||
*/
|
||||
private async buildRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
currentBookingUid,
|
||||
userTimeZone,
|
||||
}: {
|
||||
fromRescheduleUid: string;
|
||||
currentBookingUid: string;
|
||||
userTimeZone: string;
|
||||
}): Promise<EnrichedAuditLog | null> {
|
||||
const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(fromRescheduleUid);
|
||||
|
||||
// Find the specific log that created this booking by matching rescheduledToUid
|
||||
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
|
||||
rescheduledLogs,
|
||||
rescheduledToBookingUid: currentBookingUid,
|
||||
});
|
||||
|
||||
if (!rescheduledLog) {
|
||||
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
|
||||
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
|
||||
return null;
|
||||
}
|
||||
|
||||
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
|
||||
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
|
||||
|
||||
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
|
||||
// by replacing rescheduledToUid with rescheduledFromUid
|
||||
const transformedDisplayJson = enrichedLog.displayJson
|
||||
? {
|
||||
...enrichedLog.displayJson,
|
||||
rescheduledFromUid: fromRescheduleUid,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...enrichedLog,
|
||||
// Override bookingUid to associate with the current booking being viewed
|
||||
bookingUid: currentBookingUid,
|
||||
displayJson: transformedDisplayJson,
|
||||
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
|
||||
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
userTimeZone,
|
||||
storedData: parsedData,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async enrichImpersonator(context: BookingAuditContext | null): Promise<{
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null> {
|
||||
if (!context?.impersonatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy });
|
||||
if (!impersonatorUser) {
|
||||
return {
|
||||
displayName: "Deleted User",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: impersonatorUser.name || impersonatorUser.email,
|
||||
displayEmail: impersonatorUser.email,
|
||||
displayAvatar: impersonatorUser.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich actor information with user details if userUuid exists
|
||||
*/
|
||||
private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
}> {
|
||||
switch (actor.type) {
|
||||
case "SYSTEM":
|
||||
return {
|
||||
displayName: "Cal.com",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
|
||||
case "GUEST":
|
||||
return {
|
||||
displayName: actor.name || "Guest",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
|
||||
case "APP": {
|
||||
if (actor.credentialId) {
|
||||
const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId);
|
||||
if (credential) {
|
||||
return {
|
||||
displayName: "Deleted User",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
displayName: getAppNameFromSlug({ appSlug: credential.appId }),
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
// Expect that on Credential deletion name would have been set
|
||||
displayName: actor.name ?? "Deleted App",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// We allow creating App actor without credentialId
|
||||
return {
|
||||
displayName: impersonatorUser.name || impersonatorUser.email,
|
||||
displayEmail: impersonatorUser.email,
|
||||
displayAvatar: impersonatorUser.avatarUrl || null,
|
||||
displayName: actor.name ?? "Unknown App",
|
||||
// We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich actor information with user details if userUuid exists
|
||||
*/
|
||||
private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
}> {
|
||||
switch (actor.type) {
|
||||
case "SYSTEM":
|
||||
return {
|
||||
displayName: "Cal.com",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
|
||||
case "GUEST":
|
||||
return {
|
||||
displayName: actor.name || "Guest",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
|
||||
case "APP": {
|
||||
if (actor.credentialId) {
|
||||
const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId);
|
||||
if (credential) {
|
||||
return {
|
||||
displayName: getAppNameFromSlug({ appSlug: credential.appId }),
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
// Expect that on Credential deletion name would have been set
|
||||
displayName: actor.name ?? "Deleted App",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
// We allow creating App actor without credentialId
|
||||
return {
|
||||
displayName: actor.name ?? "Unknown App",
|
||||
// We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "ATTENDEE": {
|
||||
if (!actor.attendeeId) {
|
||||
throw new Error("Attendee ID is required for ATTENDEE actor");
|
||||
}
|
||||
const attendee = await this.attendeeRepository.findById(actor.attendeeId);
|
||||
if (attendee) {
|
||||
return {
|
||||
displayName: attendee.name || attendee.email,
|
||||
displayEmail: attendee.email,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: "Deleted Attendee",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "USER": {
|
||||
if (!actor.userUuid) {
|
||||
throw new Error("User UUID is required for USER actor");
|
||||
}
|
||||
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
|
||||
if (actorUser) {
|
||||
return {
|
||||
displayName: actorUser.name || actorUser.email,
|
||||
displayEmail: actorUser.email,
|
||||
displayAvatar: actorUser.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: "Deleted User",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
case "ATTENDEE": {
|
||||
if (!actor.attendeeId) {
|
||||
throw new Error("Attendee ID is required for ATTENDEE actor");
|
||||
}
|
||||
const attendee = await this.attendeeRepository.findById(actor.attendeeId);
|
||||
if (attendee) {
|
||||
return {
|
||||
displayName: attendee.name || attendee.email,
|
||||
displayEmail: attendee.email,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: "Deleted Attendee",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "USER": {
|
||||
if (!actor.userUuid) {
|
||||
throw new Error("User UUID is required for USER actor");
|
||||
}
|
||||
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
|
||||
if (actorUser) {
|
||||
return {
|
||||
displayName: actorUser.name || actorUser.email,
|
||||
displayEmail: actorUser.email,
|
||||
displayAvatar: actorUser.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: "Deleted User",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user