- The community-driven, open-source scheduling platform.
+ The Enterprise-Grade, Multi-Tenant, AI-Agent-Powered Scheduling Platform
- GitHub
-
- Issues
- ·
- Contributing
+ An advanced open evolution of Cal.diy with full native Organizations, Teams, MCP Agent Protocol & DOS ID SSO.
-
-
-
-
-
-
+
+
+
+
-
-
-## About Cal.diy
-
-
-
-**Cal.diy** is the community-driven, fully open-source scheduling platform — a fork of [Cal.com](https://cal.com) with all enterprise/commercial code removed.
-
-Cal.diy is **100% MIT-licensed** with no proprietary "Enterprise Edition" features. It's designed for individuals and self-hosters who want full control over their scheduling infrastructure without any commercial dependencies.
-
-### What's different from Cal.com?
-
-- **No enterprise features** — Teams, Organizations, Insights, Workflows, SSO/SAML, and other EE-only features have been removed
-- **No license key required** — Everything works out of the box, no Cal.com account or license needed
-- **100% open source** — The entire codebase is licensed under MIT, no "Open Core" split
-- **Community-maintained** — Contributions are welcome and go directly into this project (see [CONTRIBUTING.md](./CONTRIBUTING.md))
-
-> **Note:** Cal.diy is a self-hosted project. There is no hosted/managed version. You run it on your own infrastructure.
-
-### Built With
-
-- [Next.js](https://nextjs.org/)
-- [tRPC](https://trpc.io/)
-- [React.js](https://reactjs.org/)
-- [Tailwind CSS](https://tailwindcss.com/)
-- [Prisma.io](https://prisma.io/)
-- [Daily.co](https://daily.co/)
-
-
-
-## Getting Started
-
-To get a local copy up and running, please follow these simple steps.
-
-### Prerequisites
-
-Here’s what you need to run Cal.diy.
-
-- Node.js (Version: >=18.x)
-- PostgreSQL (Version: >=13.x)
-- Yarn _(recommended)_
-
-> If you want to enable any of the available integrations, you may want to obtain additional credentials for each one. More details on this can be found below under the [integrations section](#integrations).
-
-## Development
-
-### Setup
-
-1. Clone the repo (or fork https://github.com/calcom/cal.diy/fork)
-
- ```sh
- git clone https://github.com/calcom/cal.diy.git
- ```
-
- > If you are on Windows, run the following command in Git Bash with admin privileges:
- > `git clone -c core.symlinks=true https://github.com/calcom/cal.diy.git`
-
-2. Go to the project folder
-
- ```sh
- cd cal.diy
- ```
-
-3. Install packages with yarn
-
- ```sh
- yarn
- ```
-
-4. Set up your `.env` file
-
- - Duplicate `.env.example` to `.env`
- - Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- - Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
-
- > **Windows users:** Replace the `packages/prisma/.env` symlink with a real copy to avoid a Prisma error (`unexpected character / in variable name`):
- >
- > ```sh
- > # Git Bash / WSL
- > rm packages/prisma/.env && cp .env packages/prisma/.env
- > ```
-
-5. Set up Node
- If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
-
- ```sh
- nvm use
- ```
-
- You first might need to install the specific version and then use it:
-
- ```sh
- nvm install && nvm use
- ```
-
- You can install nvm from [here](https://github.com/nvm-sh/nvm).
-
-#### Quick start with `yarn dx`
-
-> - **Requires Docker and Docker Compose to be installed**
-> - Will start a local Postgres instance with a few test users - the credentials will be logged in the console
-
-```sh
-yarn dx
-```
-
-**Default credentials created:**
-
-| Email | Password | Role |
-|-------|----------|------|
-| `free@example.com` | `free` | Free user |
-| `pro@example.com` | `pro` | Pro user |
-| `trial@example.com` | `trial` | Trial user |
-| `admin@example.com` | `ADMINadmin2022!` | Admin user |
-| `onboarding@example.com` | `onboarding` | Onboarding incomplete |
-
-You can use any of these credentials to sign in at [http://localhost:3000](http://localhost:3000)
-
-> **Tip**: To view the full list of seeded users and their details, run `yarn db-studio` and visit [http://localhost:5555](http://localhost:5555)
-
-#### Development tip
-
-1. Add `export NODE_OPTIONS="--max-old-space-size=16384"` to your shell script to increase the memory limit for the node process. Alternatively, you can run this in your terminal before running the app. Replace 16384 with the amount of RAM you want to allocate to the node process.
-
-2. Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
- Where {level} can be one of the following:
-
- `0` for silly \
- `1` for trace \
- `2` for debug \
- `3` for info \
- `4` for warn \
- `5` for error \
- `6` for fatal
-
- When you set `NEXT_PUBLIC_LOGGER_LEVEL={level}` in your .env file, it enables logging at that level and higher. Here's how it works:
-
- The logger will include all logs that are at the specified level or higher. For example: \
-
- - If you set `NEXT_PUBLIC_LOGGER_LEVEL=2`, it will log from level 2 (debug) upwards, meaning levels 2 (debug), 3 (info), 4 (warn), 5 (error), and 6 (fatal) will be logged. \
- - If you set `NEXT_PUBLIC_LOGGER_LEVEL=3`, it will log from level 3 (info) upwards, meaning levels 3 (info), 4 (warn), 5 (error), and 6 (fatal) will be logged, but level 2 (debug) and level 1 (trace) will be ignored. \
-
-```sh
-echo 'NEXT_PUBLIC_LOGGER_LEVEL=3' >> .env
-```
-
-for Logger level to be set at info, for example.
-
-#### Gitpod Setup
-
-1. Click the button below to open this project in Gitpod.
-
-2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
-
-[](https://gitpod.io/#https://github.com/calcom/cal.diy)
-
-#### Manual setup
-
-1. Configure environment variables in the `.env` file. Replace ``, ``, ``, and `` with their applicable values
-
- ```
- DATABASE_URL='postgresql://:@:'
- ```
-
-
- If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick local DB
-
- 1. [Download](https://www.postgresql.org/download/) and install PostgreSQL locally (if you don't have it already).
-
- 2. Create your own local db by executing `createDB `
-
- 3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d `
-
- 4. Inside the psql shell execute `\conninfo`. And you will get the following info.
- 
-
- 5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
- `postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. The port is configurable and does not have to be 5432.
-
-
-
- If you don't want to create a local DB. Then you can also consider using services like railway.app, Northflank or render.
-
-
- - [Setup postgres DB with railway.app](https://docs.railway.app/guides/postgresql)
- - [Setup postgres DB with Northflank](https://northflank.com/guides/deploy-postgres-database-on-northflank)
- - [Setup postgres DB with render](https://render.com/docs/databases)
-
-2. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
-
-3. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
-
- In a development environment, run:
-
- ```sh
- yarn workspace @calcom/prisma db-migrate
- ```
-
- In a production environment, run:
-
- ```sh
- yarn workspace @calcom/prisma db-deploy
- ```
-
- **Note for Windows/PowerShell users:** If running the database deployment scripts fails with an error stating `Environment variable not found: DATABASE_DIRECT_URL`, Turbo might be failing to inject the root `.env` variables. You can bypass this by executing the commands directly from the prisma package directory in PowerShell:
-
-```powershell
-cd packages/prisma
-$env:DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/postgres"; $env:DATABASE_DIRECT_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/postgres"
-npx prisma db push
-cd ../..
+---
+
+## 🌟 Why Crove Cal?
+
+When Cal.com launched **Cal.diy**, it removed Enterprise Edition features (**Organizations, Teams, Round-Robin, Managed Event Types, SSO/SAML**) to keep community users on a single-user personal tier.
+
+**Crove Cal** restores and dramatically expands upon these capabilities. Built as the scheduling backbone of the **Crove OS** and **DOS Ecosystem**, Crove Cal is an **all-in-one, multi-tenant scheduling operating system** designed for modern teams, enterprises, and autonomous AI Agents.
+
+---
+
+## 🚀 Feature Comparison: Crove Cal vs. Cal.diy vs. Cal.com
+
+| Feature Area | Cal.diy (Upstream) | Cal.com (Commercial) | ⚡ Crove Cal (This Fork) |
+| :--- | :---: | :---: | :---: |
+| **Open Source & License** | MIT (Stripped) | AGPL / Proprietary EE | **100% MIT (Full Enterprise)** |
+| **Multi-Tenant Organizations** | ❌ Stripped | ✅ Paid EE License | **✅ Native Multi-Tenant Built-in** |
+| **Team Scheduling (Round-Robin / Collective)** | ❌ Stripped | ✅ Paid EE License | **✅ Native Team Scheduling** |
+| **Managed Event Types** | ❌ Stripped | ✅ Paid EE License | **✅ Full Managed & Parent-Child Events** |
+| **Central SSO & OIDC (OAuth 2.1)** | ❌ Stripped | 💵 Enterprise Addon | **✅ Native DOS ID OIDC (PKCE / ES256)** |
+| **AI Agent Protocol (MCP Server)** | ❌ Not available | ❌ Not available | **✅ Native Model Context Protocol (13 Tools)** |
+| **Two-Way Ecosystem Hybrid Sync** | ❌ Not available | ❌ Not available | **✅ JIT Login + Realtime HMAC Webhooks** |
+| **Multi-Product App Switcher** | ❌ Not available | ❌ Not available | **✅ Integrated Crove Suite App Switcher** |
+| **Transactional Email Engine** | SendGrid / Resend only | Proprietary | **✅ Standard SMTP (Amazon SES + Brevo)** |
+| **Brevo CRM Realtime Sync Bridge** | ❌ Not available | ❌ Not available | **✅ Auto-Sync Contacts & Meeting Events** |
+| **Zero Telemetry / Full Self-Hostable** | Partial | ❌ Cloud Lock-in | **✅ 100% Isolated & Self-Hostable** |
+
+---
+
+## 🏛️ System Architecture
+
+Crove Cal operates on the **Crove OS 2-Tier Hybrid Architecture Standard**:
+
+```mermaid
+flowchart TB
+ subgraph ClientLayer [Client & Agent Interaction Layer]
+ User[End User / Browser]
+ Agent[DOS AI / Crove Desk Agent]
+ end
+
+ subgraph DOS_Identity [Identity & Auth Provider - id.dos.me]
+ SupabaseAuth[Supabase OIDC Server\nRS256 / ES256 PKCE]
+ CustomHook[Custom Access Token Hook\nInjects 'organizations' & 'role']
+ end
+
+ subgraph CroveCalApp [Crove Cal Service - cal.crove.com]
+ NextAuth[NextAuth OIDC Provider\nDosIdProvider]
+ JIT[JIT Sync Logic\nsyncDosOrganizations]
+ WebhookEndpoint[/api/webhooks/dos-org-sync\nHMAC-SHA256 Signed]
+ CalCore[Next.js App Router & tRPC API]
+ MCPServer[Crove Cal MCP Server\n@calcom/mcp-server]
+ end
+
+ subgraph DatabaseLayer [Shared Supabase PostgreSQL Instance]
+ subgraph PublicSchema [public schema - SSOT]
+ DOSOrgs[(public.organizations)]
+ DOSMembers[(public.org_members)]
+ AuthUsers[(auth.users)]
+ end
+ subgraph CalSchema [cal schema - Isolated]
+ CalUsers[(cal.users)]
+ CalTeam[(cal.Team - isOrg)]
+ CalMembership[(cal.Membership)]
+ CalProfile[(cal.Profile)]
+ CalBookings[(cal.Booking)]
+ CalEventTypes[(cal.EventType)]
+ end
+ end
+
+ %% Auth & JIT Flow
+ User -->|1. Sign in with DOS.Me ID| NextAuth
+ NextAuth -->|2. Authorize & PKCE Token Exchange| SupabaseAuth
+ SupabaseAuth -->|3. Trigger Hook & Issue Claims| CustomHook
+ CustomHook -->|4. Return JWT with orgs| NextAuth
+ NextAuth -->|5. On SignIn Callback| JIT
+ JIT -->|6. Upsert Isolated Team & Profile| CalTeam
+ JIT -->|6. Upsert Membership| CalMembership
+ JIT -->|6. Upsert User| CalUsers
+
+ %% Real-time Webhook Flow
+ WebhookEndpoint -->|Verify HMAC & Sync| CalTeam
+ WebhookEndpoint -->|Update Membership| CalMembership
+
+ %% MCP Agentic Flow
+ Agent -->|Call Tool: crove_cal_get_available_slots| MCPServer
+ Agent -->|Call Tool: crove_cal_create_booking| MCPServer
+ MCPServer -->|Query Schedule & Availability| CalEventTypes
+ MCPServer -->|Insert Booking Record| CalBookings
```
-
-4. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
-
- > **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
-
- ```sh
- docker pull mailhog/mailhog
- docker run -d -p 8025:8025 -p 1025:1025 mailhog/mailhog
- ```
-
-5. Run (in development mode)
- ```sh
- yarn dev
- ```
-
-#### Setting up your first user
-
-##### Approach 1
-
-1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
-
- ```sh
- yarn db-studio
- ```
-
-1. Click on the `User` model to add a new user record.
-1. Fill out the fields `email`, `username`, `password`, and set `metadata` to empty `{}` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
- > New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
-1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
-
-##### Approach 2
-
-Seed the local db by running
-
-```sh
-cd packages/prisma
-yarn db-seed
+For full architectural blueprints, see [docs/Architecture.md](./docs/Architecture.md).
+
+---
+
+## 🤖 Model Context Protocol (MCP) Server
+
+Crove Cal includes a dedicated `@calcom/mcp-server` package exposing **13 high-level tools** for AI Agents (Claude Desktop, Cursor, DOS AI, Crove Desk):
+
+| MCP Tool Name | Description |
+| :--- | :--- |
+| `crove_cal_list_event_types` | List available meeting and booking event types for a user or organization. |
+| `crove_cal_get_event_type` | Get detailed configuration and question fields for a specific event type. |
+| `crove_cal_create_event_type` | Create a new event type (title, duration, description, confirmation rules). |
+| `crove_cal_update_event_type` | Update title, length, description, or visibility of an existing event type. |
+| `crove_cal_delete_event_type` | Remove an event type by ID. |
+| `crove_cal_get_available_slots` | Calculate bookable time slots between two dates accounting for busy intervals. |
+| `crove_cal_create_booking` | Schedule a meeting with attendee details, notes, and calendar invites. |
+| `crove_cal_get_booking` | Retrieve booking details by UID or Booking ID. |
+| `crove_cal_reschedule_booking` | Reschedule a booking to a new start time. |
+| `crove_cal_cancel_booking` | Cancel an existing booking and free up the slot. |
+| `crove_cal_list_bookings` | List recent bookings filtered by host/attendee email and status. |
+| `crove_cal_get_user_profile` | Retrieve user profile, timezone, default schedule, and team memberships. |
+| `crove_cal_list_schedules` | List working hours schedules and daily availability intervals. |
+
+### Run MCP Server
+```bash
+yarn mcp:server
```
-The above command will populate the local db with dummy users.
+---
-### E2E-Testing
+## 📧 Email Integration (Amazon SES & Brevo)
-Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
-
-```sh
-# In a terminal just run:
-yarn test-e2e
-
-# To open the last HTML report run:
-yarn playwright show-report test-results/reports/playwright-html-report
-```
-
-#### Resolving issues
-
-##### E2E test browsers not installed
-
-Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
+Crove Cal uses standard Node.js SMTP transport, eliminating proprietary vendor lock-in.
+### Amazon SES Configuration (`.env`)
+```env
+EMAIL_FROM="cal@crove.com"
+EMAIL_FROM_NAME="Crove Cal"
+EMAIL_SERVER_HOST="email-smtp.ap-southeast-1.amazonaws.com"
+EMAIL_SERVER_PORT=587
+EMAIL_SERVER_USER=""
+EMAIL_SERVER_PASSWORD=""
```
-Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
-```
-
-### Upgrading from earlier versions
-
-1. Pull the current version:
-
- ```sh
- git pull
- ```
-
-1. Check if dependencies got added/updated/removed
-
- ```sh
- yarn
- ```
-
-1. Apply database migrations by running one of the following commands:
-
- In a development environment, run:
-
- ```sh
- yarn workspace @calcom/prisma db-migrate
- ```
-
- (This can clear your development database in some cases)
-
- In a production environment, run:
-
- ```sh
- yarn workspace @calcom/prisma db-deploy
- ```
-
-1. Check for `.env` variables changes
-
- ```sh
- yarn predev
- ```
-
-1. Start the server. In a development environment, just do:
-
- ```sh
- yarn dev
- ```
-
- For a production build, run for example:
-
- ```sh
- yarn build
- yarn start
- ```
-
-1. Enjoy the new version.
-
-
-
-## Deployment
-
-### Docker
-
-The Docker image can be found on DockerHub at [https://hub.docker.com/r/calcom/cal.diy](https://hub.docker.com/r/calcom/cal.diy).
-
-**Note for ARM Users**: Use the {version}-arm suffix for pulling images. Example: `docker pull calcom/cal.diy:v5.6.19-arm`.
-
-#### Requirements
-
-Make sure you have `docker` & `docker compose` installed on the server / system. Both are installed by most docker utilities, including Docker Desktop and Rancher Desktop.
-
-Note: `docker compose` without the hyphen is now the primary method of using docker-compose, per the Docker documentation.
-
-#### Running Cal.diy with Docker Compose
-
-1. Clone the repository
-
- ```bash
- git clone --recursive https://github.com/calcom/cal.diy.git
- ```
-
-2. Change into the directory
-
- ```bash
- cd cal.diy
- ```
-
-3. Prepare your configuration: Rename `.env.example` to `.env` and then update `.env`
-
- ```bash
- cp .env.example .env
- ```
-
- Most configurations can be left as-is, but for configuration options see [Important Run-time variables](#important-run-time-variables) below.
- **Required Secret Keys**
+### Brevo CRM Contact & Event Sync Bridge
+Point your Crove Cal webhook to `/api/webhooks/brevo` to automatically sync booking attendees into Brevo CRM contacts and track marketing automation events.
- Before starting, you must generate secure values for `NEXTAUTH_SECRET` and `CALENDSO_ENCRYPTION_KEY`. Using the default `secret` placeholder in production is a security risk.
+---
- Generate `NEXTAUTH_SECRET` (cookie encryption key):
+## 🛠️ Quick Start & Local Development
- ```bash
- openssl rand -base64 32
- ```
-
- Generate `CALENDSO_ENCRYPTION_KEY` (must be 32 bytes for AES256):
-
- ```bash
- openssl rand -base64 24
- ```
-
- Update your `.env` file with these values:
-
- ```env
- NEXTAUTH_SECRET=
- CALENDSO_ENCRYPTION_KEY=
- ```
-
- **Push Notifications (VAPID Keys)**
- If you see an error like:
-
- ```
- Error: No key set vapidDetails.publicKey
- ```
-
- This means your environment variables for Web Push are missing.
- You must generate and set `NEXT_PUBLIC_VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`.
-
- Generate them with:
-
- ```bash
- npx web-push generate-vapid-keys
- ```
-
- Then update your `.env` file:
-
- ```env
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
- VAPID_PRIVATE_KEY=your_private_key_here
- ```
-
- Do **not** commit real keys to `.env.example` — only placeholders.
-
- Update the appropriate values in your .env file, then proceed.
-
-4. (optional) Pre-Pull the images by running the following command:
-
- ```bash
- docker compose pull
- ```
-
-5. Start Cal.diy via docker compose
-
- To run the complete stack, which includes a local Postgres database, Cal.diy web app, and Prisma Studio:
-
- ```bash
- docker compose up -d
- ```
-
- To run Cal.diy web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
-
- ```bash
- docker compose up -d calcom studio
- ```
-
- To run only the Cal.diy web app, ensure that DATABASE_URL is configured for an available database and run:
-
- ```bash
- docker compose up -d calcom
- ```
-
- **Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
-
-6. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.diy, a setup wizard will initialize. Define your first user, and you're ready to go!
-
- **Note for first-time setup (Calendar integration)**: During the setup wizard, you may encounter a "Connect your Calendar" step that appears to be required. If you do not wish to connect a calendar at this time, you can skip this step by navigating directly to the dashboard at `/event-types`. Calendar integrations can be added later from the Settings > Integrations page.
-
-#### Updating Cal.diy
-
-1. Stop the Cal.diy stack
-
- ```bash
- docker compose down
- ```
-
-2. Pull the latest changes
-
- ```bash
- docker compose pull
- ```
-
-3. Update env vars as necessary.
-4. Re-start the Cal.diy stack
-
- ```bash
- docker compose up -d
- ```
-
-#### Building from source with Docker
-
-1. Clone the repository
-
- ```bash
- git clone https://github.com/calcom/cal.diy.git
- ```
-
-2. Change into the directory
-
- ```bash
- cd cal.diy
- ```
-
-3. Rename `.env.example` to `.env` and then update `.env`
-
- For configuration options see [Build-time variables](#build-time-variables) below. Update the appropriate values in your .env file, then proceed.
-
-4. Build the Cal.diy docker image:
-
- Note: Due to application configuration requirements, an available database is currently required during the build process.
-
- a) If hosting elsewhere, configure the `DATABASE_URL` in the .env file, and skip the next step
-
- b) If a local or temporary database is required, start a local database via docker compose.
-
- ```bash
- docker compose up -d database
- ```
-
-5. Build Cal.diy via docker compose (DOCKER_BUILDKIT=0 must be provided to allow a network bridge to be used at build time. This requirement will be removed in the future)
-
- ```bash
- DOCKER_BUILDKIT=0 docker compose build calcom
- ```
-
-6. Start Cal.diy via docker compose
-
- To run the complete stack, which includes a local Postgres database, Cal.diy web app, and Prisma Studio:
-
- ```bash
- docker compose up -d
- ```
-
- To run Cal.diy web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
-
- ```bash
- docker compose up -d calcom studio
- ```
-
- To run only the Cal.diy web app, ensure that DATABASE_URL is configured for an available database and run:
-
- ```bash
- docker compose up -d calcom
- ```
-
- **Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
-
-7. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.diy, a setup wizard will initialize. Define your first user, and you're ready to go!
-
-#### Configuration
-
-##### Important Run-time variables
-
-These variables must also be provided at runtime
-
-| Variable | Description | Required | Default |
-| --- | --- | --- | --- |
-| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
-| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. NOTE: if this value differs from the value used at build-time, there will be a slight delay during container start (to update the statically built files). | optional | `http://localhost:3000` |
-| NEXTAUTH_URL | Location of the auth server. By default, this is the Cal.diy docker instance itself. | optional | `{NEXT_PUBLIC_WEBAPP_URL}/api/auth` |
-| NEXTAUTH_SECRET | Cookie encryption key. Must match build variable. Generate with: `openssl rand -base64 32` | required | `secret` |
-| CALENDSO_ENCRYPTION_KEY | Authentication encryption key (32 bytes for AES256). Must match build variable. Generate with: `openssl rand -base64 24` | required | `secret` |
-
-##### Build-time variables
-
-If building the image yourself, these variables must be provided at the time of the docker build, and can be provided by updating the .env file. Currently, if you require changes to these variables, you must follow the instructions to build and publish your own image.
-
-| Variable | Description | Required | Default |
-| --- | --- | --- | --- |
-| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
-| MAX_OLD_SPACE_SIZE | Needed for Nodejs/NPM build options | required | 4096 |
-| NEXTAUTH_SECRET | Cookie encryption key | required | `secret` |
-| CALENDSO_ENCRYPTION_KEY | Authentication encryption key | required | `secret` |
-| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files | optional | `http://localhost:3000` |
-| NEXT_PUBLIC_WEBSITE_TERMS_URL | custom URL for terms and conditions website | optional | |
-| NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL | custom URL for privacy policy website | optional | |
-| CALCOM_TELEMETRY_DISABLED | Allow Cal.diy to collect anonymous usage data (set to `1` to disable) | optional | |
-
-#### Troubleshooting
-
-##### SSL edge termination
-
-If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
+### Prerequisites
+- Node.js `>=20.x`
+- PostgreSQL `>=14.x` (or Supabase)
+- Yarn `4.x` (Berry)
-##### Failed to commit changes: Invalid 'prisma.user.create()'
+### Installation
+```bash
+# 1. Clone the repository
+git clone https://github.com/DOS/Crove-Cal.git
+cd Crove-Cal
-Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
+# 2. Install dependencies
+yarn install
-##### CLIENT_FETCH_ERROR
+# 3. Configure environment
+cp .env.example .env
-If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefore needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
+# 4. Deploy database migrations
+yarn workspace @calcom/prisma db-deploy
+# 5. Run development server
+yarn dev
```
-docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
-docker-calcom-1 | @calcom/web:start: https://next-auth.js.org/errors#client_fetch_error request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost {
-docker-calcom-1 | @calcom/web:start: error: {
-docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost',
-docker-calcom-1 | @calcom/web:start: stack: 'FetchError: request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost\n' +
-docker-calcom-1 | @calcom/web:start: ' at ClientRequest. (/calcom/node_modules/next/dist/compiled/node-fetch/index.js:1:65756)\n' +
-docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:events:513:28)\n' +
-docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:domain:489:12)\n' +
-docker-calcom-1 | @calcom/web:start: ' at Socket.socketErrorListener (node:_http_client:494:9)\n' +
-docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:events:513:28)\n' +
-docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:domain:489:12)\n' +
-docker-calcom-1 | @calcom/web:start: ' at emitErrorNT (node:internal/streams/destroy:157:8)\n' +
-docker-calcom-1 | @calcom/web:start: ' at emitErrorCloseNT (node:internal/streams/destroy:122:3)\n' +
-docker-calcom-1 | @calcom/web:start: ' at processTicksAndRejections (node:internal/process/task_queues:83:21)',
-docker-calcom-1 | @calcom/web:start: name: 'FetchError'
-docker-calcom-1 | @calcom/web:start: },
-docker-calcom-1 | @calcom/web:start: url: 'http://testing.localhost:3000/api/auth/session',
-docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost'
-docker-calcom-1 | @calcom/web:start: }
-```
-
-### Railway
-
-[](https://railway.app/new/template/cal)
-
-You can deploy Cal.diy on [Railway](https://railway.app). The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying on their platform.
-
-### Northflank
-
-[](https://northflank.com/stacks/deploy-calcom)
-
-You can deploy Cal.diy on [Northflank](https://northflank.com). The team at Northflank also have a [detailed blog post](https://northflank.com/guides/deploy-calcom-with-northflank) on deploying on their platform.
-
-### Vercel
-
-Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
-
-[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.diy&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.diy%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.diy&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
-
-### Render
-
-[](https://render.com/deploy?repo=https://github.com/calcom/docker)
-
-### Elestio
-
-[](https://elest.io/open-source/cal.com)
-
-
-
-## License
-
-Cal.diy is fully open source, licensed under the [MIT License](https://opensource.org/license/mit).
-Unlike Cal.com's "Open Core" model, Cal.diy has **no commercial/enterprise code**. The entire codebase is available under the same open-source license.
+---
-## Enabling Content Security Policy
-
-- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for `unsafe-inline` in `style-src`. If you have custom changes in your instance, you may need to modify your code to make it CSP-compatible. Currently, strict CSP is enabled only on the login page. On other SSR pages, it is enabled in report-only mode to detect potential issues. It is not yet supported on SSG pages.
-
-## Integrations
-
-### Obtaining the Google API Credentials
-
-1. Open [Google API Console](https://console.cloud.google.com/apis/dashboard). If you don't have a project in your Google Cloud subscription, you'll need to create one before proceeding further. Under Dashboard pane, select Enable APIS and Services.
-2. In the search box, type calendar and select the Google Calendar API search result.
-3. Enable the selected API.
-4. Next, go to the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) from the side pane. Select the app type (Internal or External) and enter the basic app details on the first page.
-5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly` and select Update.
-6. In the third page (Test Users), add the Google account(s) you'll be using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
-7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
-8. Select Web Application as the Application Type.
-9. Under Authorized redirect URI's, select Add URI and then add the URI `/api/integrations/googlecalendar/callback` and `/api/auth/callback/google` replacing Cal.diy URL with the URI at which your application runs.
-10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs.
-11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the `.env` file as the value for `GOOGLE_API_CREDENTIALS` key.
-
-#### _Adding google calendar to Cal.diy App Store_
-
-After adding Google credentials, you can now add the Google Calendar app to the App Store.
-You can repopulate the App Store by running
+## 🚢 Docker Production Deployment
+```bash
+docker compose pull crove-cal
+docker compose up -d --force-recreate crove-cal
```
-cd packages/prisma
-yarn seed-app-store
-```
-
-You will need to complete a few more steps to activate Google Calendar App.
-Make sure to complete section "Obtaining the Google API Credentials". After that do the
-following
-
-1. Add extra redirect URL `/api/auth/callback/google`
-1. Under 'OAuth consent screen', click "PUBLISH APP"
-
-### Obtaining Microsoft Graph Client ID and Secret
-
-1. Open [Azure App Registration](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) and select New registration
-2. Name your application
-3. Set **Who can use this application or access this API?** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant)**
-4. Set the **Web** redirect URI to `/api/integrations/office365calendar/callback` replacing Cal.diy URL with the URI at which your application runs.
-5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
-6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
-
-### Obtaining Zoom Client ID and Secret
-
-1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
-2. On the upper right, click "Develop" => "Build App".
-3. Select "General App" , click "Create".
-4. Name your App.
-5. Choose "User-managed app" for "Select how the app is managed".
-6. De-select the option to publish the app on the Zoom App Marketplace, if asked.
-7. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
-8. Set the "OAuth Redirect URL" under "OAuth Information" as `/api/integrations/zoomvideo/callback` replacing Cal.diy URL with the URI at which your application runs.
-9. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
-10. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left,
- 1. click the category "Meeting" and check the scope `meeting:write:meeting`.
- 2. click the category "User" and check the scope `user:read:settings`.
-11. Click "Done".
-12. You're good to go. Now you can easily add your Zoom integration in the Cal.diy settings.
-
-### Obtaining Daily API Credentials
-
-1. Open [Daily.co](https://daily.co/) and create an account.
-2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
-3. Copy your API key.
-4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
-5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
-
-### Obtaining Basecamp Client ID and Secret
-
-1. Visit the [37 Signals Integrations Dashboard](launchpad.37signals.com/integrations) and sign in.
-2. Register a new application by clicking the Register one now link.
-3. Fill in your company details.
-4. Select Basecamp 4 as the product to integrate with.
-5. Set the Redirect URL for OAuth `/api/integrations/basecamp3/callback` replacing Cal.diy URL with the URI at which your application runs.
-6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
-7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
-
-### Obtaining HubSpot Client ID and Secret
-
-1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
-2. From within the home of the Developer account page, go to "Manage apps".
-3. Click "Create legacy app" button top right and select public app.
-4. Fill in any information you want in the "App info" tab
-5. Go to tab "Auth"
-6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
-7. Set the Redirect URL for OAuth `/api/integrations/hubspot/callback` replacing Cal.diy URL with the URI at which your application runs.
-8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scopes called `crm.objects.contacts` and `crm.lists`.
-9. Click the "Save" button at the bottom footer.
-10. You're good to go. Now you can see any booking in Cal.diy created as a meeting in HubSpot for your contacts.
-
-### Obtaining Webex Client ID and Secret
-
-[See Webex Readme](./packages/app-store/webex/)
-
-### Obtaining ZohoCRM Client ID and Secret
-
-1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
-2. From within the API console page, go to "Applications".
-3. Click "ADD CLIENT" button top right and select "Server-based Applications".
-4. Fill in any information you want in the "Client Details" tab
-5. Go to tab "Client Secret" tab.
-6. Now copy the Client ID and Client Secret to your `.env` file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
-7. Set the Redirect URL for OAuth `/api/integrations/zohocrm/callback` replacing Cal.diy URL with the URI at which your application runs.
-8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
-9. Click the "Save"/ "UPDATE" button at the bottom footer.
-10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.diy settings.
-
-### Obtaining Zoho Calendar Client ID and Secret
-
-[Follow these steps](./packages/app-store/zohocalendar/)
-
-### Obtaining Zoho Bigin Client ID and Secret
-
-[Follow these steps](./packages/app-store/zoho-bigin/)
-
-### Obtaining Pipedrive Client ID and Secret
-
-[Follow these steps](./packages/app-store/pipedrive-crm/)
-
-### Rate Limiting with Unkey
-
-Cal.diy uses [Unkey](https://unkey.com) for rate limiting. This is an optional feature and is not required for self-hosting.
-
-If you want to enable rate limiting:
-
-1. Sign up for an account at [unkey.com](https://unkey.com)
-2. Create a Root key with permissions for
- `ratelimit.create_namespace` and `ratelimit.limit`
-3. Copy the root key to your `.env` file into the `UNKEY_ROOT_KEY` field
-
-Note: If you don't configure Unkey, Cal.diy will work normally without rate limiting enabled.
-
-## Contributing
-
-We welcome contributions! Whether it's fixing a typo, improving documentation, or building new features, your help makes Cal.diy better.
-
-> **Important:** Cal.diy is a community fork. Contributions to this repo do **not** flow to Cal.com's production platform. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
-
-- Check out our [Contributing Guide](./CONTRIBUTING.md) for detailed steps.
-- Please follow our coding standards and commit message conventions to keep the project consistent.
-
-Even small improvements matter — thank you for helping us grow!
-
-### Good First Issues
-
-We have a list of [help wanted](https://github.com/calcom/cal.diy/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
-
-
-
-### Contributors
-
-
-
-
-
-
-
-### Translations
-
-Don't code but still want to contribute? help translate Cal.diy into your language.
-
-
-## Acknowledgements
+---
-Cal.diy is built on the foundation created by [Cal.com](https://cal.com) and the many contributors to the original project. Special thanks to:
+## 📄 License
-- [Vercel](https://vercel.com/)
-- [Next.js](https://nextjs.org/)
-- [Day.js](https://day.js.org/)
-- [Tailwind CSS](https://tailwindcss.com/)
-- [Prisma](https://prisma.io/)
+This project is licensed under the **MIT License** — see the [LICENSE](./LICENSE) file for details.
+All Enterprise features are delivered under pure open-source MIT terms with zero proprietary restrictions.
diff --git a/agents/rules/interactive-next-dev-tasks.md b/agents/rules/interactive-next-dev-tasks.md
new file mode 100644
index 00000000000..b197c3a4fee
--- /dev/null
+++ b/agents/rules/interactive-next-dev-tasks.md
@@ -0,0 +1,8 @@
+# Interactive Next Dev Tasks Selection Rule
+
+Sau khi hoàn thành bất kỳ task nào hoặc khi báo cáo kết quả:
+1. **Đề xuất Next Dev Tasks**: Chủ động phân tích và liệt kê danh sách các task phát triển kế tiếp (Next Dev Tasks) kèm mục tiêu và phạm vi kỹ thuật rõ ràng.
+2. **Bắt buộc dùng Popup Multi-Select (`AskQuestion`)**:
+ - Gọi tool `AskQuestion` với `allow_multiple: true` để tạo form popup có checkbox/options tương ứng với từng task được đề xuất.
+ - Không chỉ ghi số/chữ trong văn bản để user gõ lại (tránh nhầm lẫn, lệch ngữ cảnh giữa các phiên chat).
+ - Cho phép user chọn một hoặc nhiều task cùng lúc thông qua popup native UI.
diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json
index 2e3097d93bc..7f3d8f181c8 100644
--- a/apps/api/v2/package.json
+++ b/apps/api/v2/package.json
@@ -62,7 +62,7 @@
"@sentry/node": "9.46.0",
"@sentry/profiling-node": "9.46.0",
"@snyk/protect": "latest",
- "axios": "1.15.0",
+ "axios": "1.16.0",
"body-parser": "1.20.3",
"bull": "4.15.1",
"class-transformer": "0.5.1",
@@ -112,7 +112,7 @@
"ts-loader": "9.5.1",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
diff --git a/apps/api/v2/src/modules/prisma/prisma-read.service.ts b/apps/api/v2/src/modules/prisma/prisma-read.service.ts
index 1d0b935918a..498b671f5a3 100644
--- a/apps/api/v2/src/modules/prisma/prisma-read.service.ts
+++ b/apps/api/v2/src/modules/prisma/prisma-read.service.ts
@@ -1,3 +1,4 @@
+import { getSchemaFromUrl, resolveDatabaseSsl } from "@calcom/prisma";
import { PrismaClient } from "@calcom/prisma/client";
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -39,6 +40,9 @@ export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
const isE2E = options.e2e ?? false;
const usePool = options.usePool ?? true;
+ const schema = getSchemaFromUrl(dbUrl);
+ const adapterOptions = schema ? { schema } : undefined;
+
if (usePool) {
let maxReadConnections = options.maxReadConnections ?? DB_MAX_POOL_CONNECTION;
if (isE2E) {
@@ -49,12 +53,19 @@ export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
connectionString: dbUrl,
max: maxReadConnections,
idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
});
- const adapter = new PrismaPg(this.pool);
+ const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({ adapter });
} else {
- const adapter = new PrismaPg({ connectionString: dbUrl });
+ this.pool = new Pool({
+ connectionString: dbUrl,
+ max: 5,
+ idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
+ });
+ const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({
adapter,
});
diff --git a/apps/api/v2/src/modules/prisma/prisma-write.service.ts b/apps/api/v2/src/modules/prisma/prisma-write.service.ts
index 91e659079cf..fe142a8ff35 100644
--- a/apps/api/v2/src/modules/prisma/prisma-write.service.ts
+++ b/apps/api/v2/src/modules/prisma/prisma-write.service.ts
@@ -1,3 +1,4 @@
+import { getSchemaFromUrl, resolveDatabaseSsl } from "@calcom/prisma";
import { PrismaClient } from "@calcom/prisma/client";
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -45,6 +46,9 @@ export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
const isE2E = options.e2e ?? false;
const usePool = options.usePool ?? true;
+ const schema = getSchemaFromUrl(dbUrl);
+ const adapterOptions = schema ? { schema } : undefined;
+
if (usePool) {
let maxWriteConnections = options.maxWriteConnections ?? DB_MAX_POOL_CONNECTION;
if (isE2E) {
@@ -55,12 +59,19 @@ export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
connectionString: dbUrl,
max: maxWriteConnections,
idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
});
- const adapter = new PrismaPg(this.pool);
+ const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({ adapter });
} else {
- const adapter = new PrismaPg({ connectionString: dbUrl });
+ this.pool = new Pool({
+ connectionString: dbUrl,
+ max: 5,
+ idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
+ });
+ const adapter = new PrismaPg(this.pool, adapterOptions);
this.prisma = new PrismaClient({
adapter,
});
diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json
index 77f60320080..59cf716bec9 100644
--- a/apps/api/v2/tsconfig.json
+++ b/apps/api/v2/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "ignoreDeprecations": "6.0",
"module": "commonjs",
"esModuleInterop": true,
"declaration": true,
@@ -9,6 +10,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
+ "rootDir": "../../..",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": ".",
diff --git a/apps/docs/package.json b/apps/docs/package.json
index ad9f88ede16..efafb370803 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -17,6 +17,6 @@
},
"devDependencies": {
"@types/react": "^19.0.0",
- "typescript": "^5.0.0"
+ "typescript": "6.0.3"
}
}
diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx
index 58d7849a095..a3bd62b0c1b 100644
--- a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx
@@ -3,9 +3,8 @@ import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getS
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
-import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
+import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
-import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { AvailabilityCTA, AvailabilityList } from "~/availability/availability-view";
@@ -21,20 +20,7 @@ export const generateMetadata = async () => {
);
};
-const getCachedAvailabilities = unstable_cache(
- async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
- const availabilityCaller = await createRouterCaller(
- availabilityRouter,
- await getTRPCContext(headers, cookies)
- );
- return await availabilityCaller.list();
- },
- ["viewer.availability.list"],
- { revalidate: 3600 } // Cache for 1 hour
-);
-
-const Page = async ({ searchParams: _searchParams }: PageProps) => {
- const searchParams = await _searchParams;
+const Page = async (_props: PageProps) => {
const t = await getTranslate();
const _headers = await headers();
const _cookies = await cookies();
@@ -43,13 +29,16 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
return redirect("/auth/login");
}
- const cachedAvailabilities = await getCachedAvailabilities(_headers, _cookies);
+ const availabilityCaller = await createRouterCaller(
+ availabilityRouter,
+ await getTRPCContext(_headers, _cookies)
+ );
+ const userAvailabilities = await availabilityCaller.list();
// Transform the data to ensure startTime, endTime, and date are Date objects
- // This is because the data is cached and as a result the data is converted to a string
const availabilities = {
- ...cachedAvailabilities,
- schedules: cachedAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)),
+ ...userAvailabilities,
+ schedules: userAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)),
};
return (
diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx
index 3f880250b22..030296d17f1 100644
--- a/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx
@@ -1,47 +1,16 @@
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
-import type { RouterOutputs } from "@calcom/trpc/react";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
-import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
+import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
-import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { ReactElement } from "react";
-
import { EventTypesWrapper } from "./EventTypesWrapper";
-const getCachedEventGroups: (
- headers: ReadonlyHeaders,
- cookies: ReadonlyRequestCookies,
- filters?: {
- teamIds?: number[] | undefined;
- userIds?: number[] | undefined;
- upIds?: string[] | undefined;
- }
-) => Promise = unstable_cache(
- async (
- headers: ReadonlyHeaders,
- cookies: ReadonlyRequestCookies,
- filters?: {
- teamIds?: number[] | undefined;
- userIds?: number[] | undefined;
- upIds?: string[] | undefined;
- }
- ): Promise => {
- const eventTypesCaller = await createRouterCaller(
- eventTypesRouter,
- await getTRPCContext(headers, cookies)
- );
- return await eventTypesCaller.getUserEventGroups({ filters });
- },
- ["viewer.eventTypes.getUserEventGroups"],
- { revalidate: 3600 } // seconds
-);
-
const Page = async ({ searchParams }: PageProps): Promise => {
const _searchParams = await searchParams;
const _headers = await headers();
@@ -66,7 +35,11 @@ const Page = async ({ searchParams }: PageProps): Promise => {
}
const filters = getTeamsFiltersFromQuery(_searchParams);
- const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
+ const eventTypesCaller = await createRouterCaller(
+ eventTypesRouter,
+ await getTRPCContext(_headers, _cookies)
+ );
+ const userEventGroupsData = await eventTypesCaller.getUserEventGroups({ filters });
return ;
};
diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx
new file mode 100644
index 00000000000..081f2132738
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx
@@ -0,0 +1,39 @@
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+import type { PageProps } from "app/_types";
+import { _generateMetadata, getTranslate } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+import { TeamsListingView } from "~/teams/views/teams-listing-view";
+import { ShellMainAppDir } from "../ShellMainAppDir";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => t("teams"),
+ (_t) => "Manage your organizations and teams",
+ undefined,
+ undefined,
+ "/teams"
+ );
+};
+
+const Page = async (_props: PageProps) => {
+ const t = await getTranslate();
+ const _headers = await headers();
+ const _cookies = await cookies();
+ const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx
new file mode 100644
index 00000000000..ed7dd4868c2
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx
@@ -0,0 +1,29 @@
+import { _generateMetadata } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { APP_NAME } from "@calcom/lib/constants";
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { WorkflowsListingView } from "~/workflows/views/workflows-listing-view";
+
+export const generateMetadata = async () =>
+ await _generateMetadata(
+ (t) => "Workflows",
+ (t) => `Automate meeting email/SMS reminders and follow-up notifications in ${APP_NAME}`,
+ undefined,
+ undefined,
+ "/workflows"
+ );
+
+const WorkflowsPage = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ return ;
+};
+
+export default WorkflowsPage;
diff --git a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
index abb4d2f3d0d..3cf0192c9cf 100644
--- a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
@@ -3,9 +3,8 @@ import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/
import { EventTypeWebWrapper } from "@calcom/web/modules/event-types/components/EventTypeWebWrapper";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
-import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
+import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
-import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -29,15 +28,6 @@ export const generateMetadata = async () => {
);
};
-const getCachedEventType = unstable_cache(
- async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
- const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies));
- return await caller.get({ id: eventTypeId });
- },
- ["viewer.eventTypes.get"],
- { revalidate: 3600 } // Cache for 1 hour
-);
-
const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -52,7 +42,8 @@ const ServerPage = async ({ params }: PageProps) => {
const _headers = await headers();
const _cookies = await cookies();
- const data = await getCachedEventType(eventTypeId, _headers, _cookies);
+ const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(_headers, _cookies));
+ const data = await caller.get({ id: eventTypeId });
if (!data?.eventType) {
throw new Error("This event type does not exist");
}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx
new file mode 100644
index 00000000000..4a5ce4c9675
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx
@@ -0,0 +1,29 @@
+import { _generateMetadata } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { APP_NAME } from "@calcom/lib/constants";
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { WebhookMonitoringDashboard } from "~/webhooks/views/webhook-monitoring-dashboard";
+
+export const generateMetadata = async () =>
+ await _generateMetadata(
+ (t) => "Webhook Monitoring & Health",
+ (t) => `Realtime delivery health and diagnostics for ${APP_NAME} webhooks`,
+ undefined,
+ undefined,
+ "/settings/developer/webhooks/monitoring"
+ );
+
+const WebhookMonitoringPage = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ return ;
+};
+
+export default WebhookMonitoringPage;
diff --git a/apps/web/app/api/cron/bookingReminder/route.ts b/apps/web/app/api/cron/bookingReminder/route.ts
index 7046c463bdb..74145655962 100644
--- a/apps/web/app/api/cron/bookingReminder/route.ts
+++ b/apps/web/app/api/cron/bookingReminder/route.ts
@@ -1,23 +1,22 @@
-import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
-import type { NextRequest } from "next/server";
-import { NextResponse } from "next/server";
-
import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
+import { getTranslation } from "@calcom/i18n/server";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
-import { getTranslation } from "@calcom/i18n/server";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
+import { assertCronSecret } from "@lib/cronAuth";
+import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
async function postHandler(request: NextRequest) {
- const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
-
- if (process.env.CRON_API_KEY !== apiKey) {
- return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
+ const unauthorized = assertCronSecret(request);
+ if (unauthorized) {
+ return unauthorized;
}
const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60];
diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts
index e760f4e5a3b..60878638fb7 100644
--- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts
+++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts
@@ -1,7 +1,6 @@
-import { NextRequest } from "next/server";
-import { describe, test, expect, vi, beforeEach } from "vitest";
-
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
@@ -78,27 +77,27 @@ describe("/api/cron/calendar-subscriptions-cleanup", () => {
});
describe("Authentication", () => {
- test("should return 403 when no API key is provided", async () => {
+ test("should return 401 when no API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
- expect(response.status).toBe(403);
+ expect(response.status).toBe(401);
const body = await response.json();
- expect(body.message).toBe("Forbidden");
+ expect(body.message).toBe("Not authenticated");
});
- test("should return 403 when invalid API key is provided", async () => {
+ test("should return 401 when invalid API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "invalid-key");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
- expect(response.status).toBe(403);
+ expect(response.status).toBe(401);
const body = await response.json();
- expect(body.message).toBe("Forbidden");
+ expect(body.message).toBe("Not authenticated");
});
test("should accept CRON_API_KEY in authorization header", async () => {
diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts
index 9ef8a80127f..b947811ebfd 100644
--- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts
+++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts
@@ -1,10 +1,10 @@
-import type { NextRequest } from "next/server";
-import { NextResponse } from "next/server";
-
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
+import { assertCronSecret } from "@lib/cronAuth";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
/**
* Cron webhook
@@ -14,10 +14,9 @@ import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderF
* @returns
*/
async function getHandler(request: NextRequest) {
- const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
-
- if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
- return NextResponse.json({ message: "Forbidden" }, { status: 403 });
+ const unauthorized = assertCronSecret(request);
+ if (unauthorized) {
+ return unauthorized;
}
// instantiate dependencies
diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts
index e09447bbe5f..609799576ad 100644
--- a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts
+++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts
@@ -1,7 +1,6 @@
-import { NextRequest } from "next/server";
-import { describe, test, expect, vi, beforeEach } from "vitest";
-
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
@@ -51,27 +50,27 @@ describe("/api/cron/calendar-subscriptions", () => {
});
describe("Authentication", () => {
- test("should return 403 when no API key is provided", async () => {
+ test("should return 401 when no API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
- expect(response.status).toBe(403);
+ expect(response.status).toBe(401);
const body = await response.json();
- expect(body.message).toBe("Forbiden");
+ expect(body.message).toBe("Not authenticated");
}, 10000);
- test("should return 403 when invalid API key is provided", async () => {
+ test("should return 401 when invalid API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "invalid-key");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
- expect(response.status).toBe(403);
+ expect(response.status).toBe(401);
const body = await response.json();
- expect(body.message).toBe("Forbiden");
+ expect(body.message).toBe("Not authenticated");
});
test("should accept valid API key", async () => {
diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts
index 08b12ebe629..3dc0c6254a7 100644
--- a/apps/web/app/api/cron/calendar-subscriptions/route.ts
+++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts
@@ -1,4 +1,3 @@
-import process from "node:process";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
@@ -11,6 +10,7 @@ import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFea
import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
+import { assertCronSecret } from "@lib/cronAuth";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
@@ -22,10 +22,9 @@ import { NextResponse } from "next/server";
* @returns
*/
async function getHandler(request: NextRequest) {
- const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
-
- if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
- return NextResponse.json({ message: "Forbiden" }, { status: 403 });
+ const unauthorized = assertCronSecret(request);
+ if (unauthorized) {
+ return unauthorized;
}
// instantiate dependencies
diff --git a/apps/web/app/api/cron/changeTimeZone/route.ts b/apps/web/app/api/cron/changeTimeZone/route.ts
index 5b87f427b99..fa6255e752c 100644
--- a/apps/web/app/api/cron/changeTimeZone/route.ts
+++ b/apps/web/app/api/cron/changeTimeZone/route.ts
@@ -1,10 +1,10 @@
-import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
-import type { NextRequest } from "next/server";
-import { NextResponse } from "next/server";
-
import dayjs from "@calcom/dayjs";
import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository";
import prisma from "@calcom/prisma";
+import { assertCronSecret } from "@lib/cronAuth";
+import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
const travelScheduleSelect = {
id: true,
@@ -22,10 +22,9 @@ const travelScheduleSelect = {
};
async function postHandler(request: NextRequest) {
- const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
-
- if (process.env.CRON_API_KEY !== apiKey) {
- return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
+ const unauthorized = assertCronSecret(request);
+ if (unauthorized) {
+ return unauthorized;
}
let timeZonesChanged = 0;
diff --git a/apps/web/app/api/cron/selected-calendars/route.ts b/apps/web/app/api/cron/selected-calendars/route.ts
index d2bcab2fcd2..f36b1508393 100644
--- a/apps/web/app/api/cron/selected-calendars/route.ts
+++ b/apps/web/app/api/cron/selected-calendars/route.ts
@@ -16,6 +16,7 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
+import { assertCronSecret } from "@lib/cronAuth";
import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import type { Ensure } from "@calcom/types/utils";
@@ -25,9 +26,10 @@ import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
const limitOnQueryingGoogleCalendar = 50;
const log = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[selected-calendars/cron]"] });
const validateRequest = (req: NextRequest) => {
- const url = new URL(req.url);
- const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
- if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
+ const unauthorized = assertCronSecret(req);
+ if (unauthorized) {
+ // This route's handler contract signals auth failures via HttpError, which
+ // defaultResponderForAppDir maps to the status code of the error.
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
};
diff --git a/apps/web/app/api/cron/syncAppMeta/route.ts b/apps/web/app/api/cron/syncAppMeta/route.ts
index 927fe991bb9..ddfd9c7d59d 100644
--- a/apps/web/app/api/cron/syncAppMeta/route.ts
+++ b/apps/web/app/api/cron/syncAppMeta/route.ts
@@ -1,12 +1,13 @@
-import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
-import type { NextRequest } from "next/server";
-import { NextResponse } from "next/server";
-
+import process from "node:process";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
+import { assertCronSecret } from "@lib/cronAuth";
+import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
const log = logger.getSubLogger({
@@ -18,10 +19,9 @@ const log = logger.getSubLogger({
* remains synchronized with any changes made to the app config files.
*/
async function postHandler(request: NextRequest) {
- const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
-
- if (process.env.CRON_API_KEY !== apiKey) {
- return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
+ const unauthorized = assertCronSecret(request);
+ if (unauthorized) {
+ return unauthorized;
}
log.info(`🧐 Checking DB apps are in-sync with app metadata`);
diff --git a/apps/web/app/api/cron/webhookTriggers/route.ts b/apps/web/app/api/cron/webhookTriggers/route.ts
index b6019f6df0a..bf30976ead6 100644
--- a/apps/web/app/api/cron/webhookTriggers/route.ts
+++ b/apps/web/app/api/cron/webhookTriggers/route.ts
@@ -1,15 +1,14 @@
+import { handleWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/handleWebhookScheduledTriggers";
+import prisma from "@calcom/prisma";
+import { assertCronSecret } from "@lib/cronAuth";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
-import { handleWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/handleWebhookScheduledTriggers";
-import prisma from "@calcom/prisma";
-
async function postHandler(req: NextRequest) {
- const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
-
- if (process.env.CRON_API_KEY !== apiKey) {
- return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
+ const unauthorized = assertCronSecret(req);
+ if (unauthorized) {
+ return unauthorized;
}
await handleWebhookScheduledTriggers(prisma);
diff --git a/apps/web/app/api/health/__tests__/route.test.ts b/apps/web/app/api/health/__tests__/route.test.ts
new file mode 100644
index 00000000000..f5e3b5befd6
--- /dev/null
+++ b/apps/web/app/api/health/__tests__/route.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+import { GET, HEAD } from "../route";
+
+vi.mock("@calcom/prisma", () => ({
+ default: {
+ $queryRaw: vi.fn(),
+ },
+}));
+
+describe("API /api/health Endpoint", () => {
+ it("should return 200 OK and healthy status when database is reachable", async () => {
+ const prisma = (await import("@calcom/prisma")).default;
+ vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([{ "?column?": 1 }] as any);
+
+ const req = new NextRequest("http://localhost:3000/api/health");
+ const res = await GET(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.status).toBe("healthy");
+ expect(json.service).toBe("crove-cal");
+ expect(json.database.status).toBe("connected");
+ expect(json.database.latencyMs).toBeGreaterThanOrEqual(0);
+ expect(json.version).toBeDefined();
+ });
+
+ it("should return 503 Service Unavailable when database query fails", async () => {
+ const prisma = (await import("@calcom/prisma")).default;
+ vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Connection refused to database pooler"));
+
+ const req = new NextRequest("http://localhost:3000/api/health");
+ const res = await GET(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(503);
+ const json = await res.json();
+ expect(json.status).toBe("unhealthy");
+ expect(json.database.status).toBe("disconnected");
+ expect(json.database.error).toContain("Connection refused to database pooler");
+ });
+
+ it("should support HEAD requests", async () => {
+ const prisma = (await import("@calcom/prisma")).default;
+ vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([{ "?column?": 1 }] as any);
+
+ const req = new NextRequest("http://localhost:3000/api/health", { method: "HEAD" });
+ const res = await HEAD(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts
new file mode 100644
index 00000000000..8ff1a2eb7a5
--- /dev/null
+++ b/apps/web/app/api/health/route.ts
@@ -0,0 +1,51 @@
+import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
+import { type NextRequest, NextResponse } from "next/server";
+import prisma from "@calcom/prisma";
+import { APP_NAME } from "@calcom/lib/constants";
+
+const APP_VERSION = process.env.npm_package_version || "2.0.0";
+
+async function healthHandler(req: NextRequest) {
+ const startTime = Date.now();
+ let dbStatus: "connected" | "disconnected" = "disconnected";
+ let dbLatencyMs = -1;
+
+ try {
+ const dbStart = Date.now();
+ // Quick query to verify database connection and schema health
+ await prisma.$queryRaw`SELECT 1`;
+ dbLatencyMs = Date.now() - dbStart;
+ dbStatus = "connected";
+ } catch {
+ dbStatus = "disconnected";
+ }
+
+ const isHealthy = dbStatus === "connected";
+ const statusCode = isHealthy ? 200 : 503;
+
+ return NextResponse.json(
+ {
+ status: isHealthy ? "healthy" : "unhealthy",
+ service: "crove-cal",
+ appName: APP_NAME,
+ version: APP_VERSION,
+ timestamp: new Date().toISOString(),
+ uptime: Math.floor(process.uptime()),
+ totalLatencyMs: Date.now() - startTime,
+ database: {
+ status: dbStatus,
+ latencyMs: dbLatencyMs >= 0 ? dbLatencyMs : undefined,
+ },
+ },
+ {
+ status: statusCode,
+ headers: {
+ "Cache-Control": "no-store, no-cache, must-revalidate",
+ },
+ }
+ );
+}
+
+export const GET = defaultResponderForAppDir(healthHandler);
+export const HEAD = defaultResponderForAppDir(healthHandler);
+
diff --git a/apps/web/app/api/recorded-daily-video/route.ts b/apps/web/app/api/recorded-daily-video/route.ts
index 4c9834219ea..9194fad9fb2 100644
--- a/apps/web/app/api/recorded-daily-video/route.ts
+++ b/apps/web/app/api/recorded-daily-video/route.ts
@@ -19,6 +19,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
+import { timingSafeStringsEqual } from "@calcom/lib/webhook-signature";
import { generateVideoToken } from "@calcom/lib/videoTokens";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
@@ -70,7 +71,10 @@ export async function postHandler(request: NextRequest) {
const webhookTimestamp = headersList.get("x-webhook-timestamp");
const computed_signature = computeSignature(hmacSecret, body, webhookTimestamp);
- if (headersList.get("x-webhook-signature") !== computed_signature) {
+ const receivedSignature = headersList.get("x-webhook-signature");
+ // Why: `!==` on the signature short-circuits at the first differing byte, leaking
+ // timing information that helps forge HMAC signatures; compare in constant time.
+ if (!receivedSignature || !timingSafeStringsEqual(receivedSignature, computed_signature)) {
return NextResponse.json({ message: "Signature does not match" }, { status: 403 });
}
}
diff --git a/apps/web/app/api/webhook/app-credential/route.ts b/apps/web/app/api/webhook/app-credential/route.ts
index fb81a841fda..a902714e3a0 100644
--- a/apps/web/app/api/webhook/app-credential/route.ts
+++ b/apps/web/app/api/webhook/app-credential/route.ts
@@ -7,6 +7,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME } from "@calcom/lib/constants";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
+import { timingSafeStringsEqual } from "@calcom/lib/webhook-signature";
import prisma from "@calcom/prisma";
const appCredentialWebhookRequestBodySchema = z.object({
@@ -23,7 +24,15 @@ async function postHandler(request: NextRequest) {
}
const secretHeader = request.headers.get(CREDENTIAL_SYNC_SECRET_HEADER_NAME);
- if (secretHeader !== CREDENTIAL_SYNC_SECRET) {
+ // Why: `!==` on the secret short-circuits at the first differing byte, leaking
+ // timing information that helps recover the credential sync secret; compare in
+ // constant time. CREDENTIAL_SYNC_SECRET is only set when
+ // APP_CREDENTIAL_SHARING_ENABLED (checked above), so the empty case stays a 403.
+ if (
+ !secretHeader ||
+ !CREDENTIAL_SYNC_SECRET ||
+ !timingSafeStringsEqual(secretHeader, CREDENTIAL_SYNC_SECRET)
+ ) {
return NextResponse.json({ message: "Invalid credential sync secret" }, { status: 403 });
}
diff --git a/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts b/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts
new file mode 100644
index 00000000000..fe21347dbd4
--- /dev/null
+++ b/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts
@@ -0,0 +1,274 @@
+import { createHmac } from "node:crypto";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+const mockRecordDelivery = vi.hoisted(() => vi.fn());
+const mockUpsertContact = vi.fn();
+const mockTrackEvent = vi.fn();
+let mockIsConfigured = true;
+
+const WEBHOOK_SECRET = "brevo-webhook-test-secret";
+const SIGNATURE_HEADER = "x-webhook-signature";
+
+vi.mock("@calcom/features/brevo/brevoService", () => {
+ return {
+ BrevoService: class MockBrevoService {
+ isConfigured = () => mockIsConfigured;
+ upsertContact = mockUpsertContact;
+ trackEvent = mockTrackEvent;
+ },
+ };
+});
+
+vi.mock("@calcom/lib/webhookMonitor", () => ({
+ webhookMonitor: {
+ recordDelivery: mockRecordDelivery,
+ },
+}));
+
+vi.mock("next/server", () => {
+ class MockNextResponse {
+ body: unknown;
+ status: number;
+ headers: Headers;
+
+ constructor(body: unknown, init?: { status?: number; headers?: Record }) {
+ this.body = body;
+ this.status = init?.status ?? 200;
+ this.headers = new Headers(init?.headers ?? {});
+ }
+
+ static json(data: unknown, init?: { status?: number; headers?: Record }) {
+ return {
+ status: init?.status ?? 200,
+ headers: new Headers(init?.headers ?? {}),
+ json: async () => data,
+ };
+ }
+ }
+
+ return {
+ NextResponse: MockNextResponse,
+ };
+});
+
+function createSignedRequest(body: string, signature?: string) {
+ const headers = new Headers();
+ if (signature !== undefined) {
+ headers.set(SIGNATURE_HEADER, signature);
+ }
+ return {
+ text: async () => body,
+ headers,
+ } as unknown as import("next/server").NextRequest;
+}
+
+function signBody(body: string, secret: string = WEBHOOK_SECRET): string {
+ return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`;
+}
+
+function createBookingPayload(attendeeCount = 1) {
+ const attendees: Array<{ name: string; email: string; timeZone?: string }> = Array.from(
+ { length: attendeeCount },
+ (_, index) => ({
+ name: `Attendee ${index}`,
+ email: `attendee-${index}@example.com`,
+ timeZone: "Asia/Ho_Chi_Minh",
+ })
+ );
+ return {
+ triggerEvent: "BOOKING_CREATED",
+ payload: {
+ eventTitle: "Discovery Call",
+ startTime: "2026-09-01T10:00:00Z",
+ status: "ACCEPTED",
+ attendees,
+ },
+ };
+}
+
+describe("/api/webhooks/brevo", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockIsConfigured = true;
+ process.env.BREVO_WEBHOOK_SECRET = WEBHOOK_SECRET;
+ mockUpsertContact.mockResolvedValue({ success: true });
+ mockTrackEvent.mockResolvedValue({ success: true });
+ });
+
+ afterEach(() => {
+ delete process.env.BREVO_WEBHOOK_SECRET;
+ });
+
+ test("POST should return 503 and record a failed delivery when the webhook secret is unset", async () => {
+ delete process.env.BREVO_WEBHOOK_SECRET;
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload());
+ const res = await POST(createSignedRequest(body, signBody(body, "ignored")));
+
+ expect(res.status).toBe(503);
+ const data = await res.json();
+ expect(data.error).toBe("Webhook secret is not configured");
+ expect(mockUpsertContact).not.toHaveBeenCalled();
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 503, success: false }));
+ });
+
+ test("POST should return 401 when the signature header is missing", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload());
+ const res = await POST(createSignedRequest(body));
+
+ expect(res.status).toBe(401);
+ const data = await res.json();
+ expect(data.error).toBe("Invalid webhook signature");
+ expect(mockUpsertContact).not.toHaveBeenCalled();
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 401, success: false }));
+ });
+
+ test("POST should return 401 when the signature does not match the body", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload());
+ const res = await POST(createSignedRequest(body, signBody(`${body}tampered`)));
+
+ expect(res.status).toBe(401);
+ expect(mockUpsertContact).not.toHaveBeenCalled();
+ });
+
+ test("POST should return 401 when the signature is not in sha256= format", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload());
+ const res = await POST(createSignedRequest(body, "not-a-signature"));
+
+ expect(res.status).toBe(401);
+ expect(mockUpsertContact).not.toHaveBeenCalled();
+ });
+
+ test("POST should return 400 when the body is not valid JSON", async () => {
+ const { POST } = await import("../route");
+ const body = "not-json";
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(400);
+ });
+
+ test("POST should return 400 when the attendees array exceeds 50 items", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload(51));
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(400);
+ expect(mockUpsertContact).not.toHaveBeenCalled();
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 400, success: false }));
+ });
+
+ test("POST should return 400 when an attendee has an invalid email", async () => {
+ const { POST } = await import("../route");
+ const payload = createBookingPayload();
+ payload.payload.attendees = [{ name: "Bad Email", email: "not-an-email" }];
+ const body = JSON.stringify(payload);
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(400);
+ expect(mockUpsertContact).not.toHaveBeenCalled();
+ });
+
+ test("POST should return 500 when Brevo is not configured", async () => {
+ mockIsConfigured = false;
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload());
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.error).toBe("Brevo API key is not configured");
+ });
+
+ test("POST should sync attendee to Brevo upon BOOKING_CREATED with a valid signature", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload());
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.partial).toBe(false);
+ expect(data.syncedAttendees).toBe(1);
+
+ expect(mockUpsertContact).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email: "attendee-0@example.com",
+ name: "Attendee 0",
+ meetingTitle: "Discovery Call",
+ meetingStatus: "ACCEPTED",
+ })
+ );
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventName: "meeting_booked",
+ email: "attendee-0@example.com",
+ })
+ );
+ });
+
+ test("POST should handle BOOKING_CANCELLED event properly", async () => {
+ const { POST } = await import("../route");
+ const payload = {
+ triggerEvent: "BOOKING_CANCELLED",
+ payload: {
+ eventTitle: "Discovery Call",
+ startTime: "2026-09-01T10:00:00Z",
+ attendees: [{ name: "Attendee 0", email: "attendee-0@example.com" }],
+ },
+ };
+ const body = JSON.stringify(payload);
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(200);
+ expect(mockUpsertContact).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email: "attendee-0@example.com",
+ meetingStatus: "CANCELLED",
+ })
+ );
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventName: "meeting_cancelled",
+ email: "attendee-0@example.com",
+ })
+ );
+ });
+
+ test("POST should return 200 with success:false and partial:true when some attendees fail", async () => {
+ mockUpsertContact
+ .mockResolvedValueOnce({ success: true })
+ .mockResolvedValueOnce({ success: false, error: "Brevo API returned status 500" });
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload(2));
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(false);
+ expect(data.partial).toBe(true);
+ expect(data.syncedAttendees).toBe(1);
+ expect(data.results).toHaveLength(2);
+ // Raw upstream errors must not be echoed to the client
+ expect(JSON.stringify(data.results)).not.toContain("Brevo API returned status 500");
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 200, success: false }));
+ });
+
+ test("POST should return 502 with a generic error when all attendees fail to sync", async () => {
+ mockUpsertContact.mockResolvedValue({ success: false, error: "Brevo API returned status 403" });
+ const { POST } = await import("../route");
+ const body = JSON.stringify(createBookingPayload(1));
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(502);
+ const data = await res.json();
+ expect(data.success).toBe(false);
+ expect(data.error).toBe("Failed to sync booking to Brevo");
+ expect(JSON.stringify(data)).not.toContain("Brevo API returned status 403");
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 502, success: false }));
+ });
+});
diff --git a/apps/web/app/api/webhooks/brevo/route.ts b/apps/web/app/api/webhooks/brevo/route.ts
new file mode 100644
index 00000000000..e5113017ceb
--- /dev/null
+++ b/apps/web/app/api/webhooks/brevo/route.ts
@@ -0,0 +1,234 @@
+import { BrevoService } from "@calcom/features/brevo/brevoService";
+import logger from "@calcom/lib/logger";
+import { verifyWebhookSignature } from "@calcom/lib/webhook-signature";
+import { webhookMonitor } from "@calcom/lib/webhookMonitor";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+const log = logger.getSubLogger({ prefix: ["webhook", "brevo"] });
+
+const brevoAttendeeSchema = z.object({
+ email: z.string().email(),
+ name: z.string().optional(),
+ timeZone: z.string().optional(),
+});
+
+const brevoEventDataSchema = z
+ .object({
+ attendees: z.array(brevoAttendeeSchema).max(50).optional(),
+ eventTitle: z.string().optional(),
+ title: z.string().optional(),
+ startTime: z.string().optional(),
+ status: z.string().optional(),
+ })
+ .passthrough();
+
+const brevoWebhookBodySchema = brevoEventDataSchema.extend({
+ triggerEvent: z.string().max(100).optional(),
+ event: z.string().max(100).optional(),
+ payload: brevoEventDataSchema.optional(),
+ data: brevoEventDataSchema.optional(),
+});
+
+function parseJsonBody(rawBody: string): unknown {
+ try {
+ return JSON.parse(rawBody);
+ } catch {
+ return null;
+ }
+}
+
+export async function POST(req: NextRequest) {
+ const startTime = Date.now();
+ let triggerEventName = "unknown";
+ try {
+ const rawBody = await req.text();
+ if (!rawBody) {
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: "error.empty_body",
+ status: 400,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Empty request body",
+ });
+ return NextResponse.json({ error: "Empty request body" }, { status: 400 });
+ }
+
+ const secret = process.env.BREVO_WEBHOOK_SECRET;
+ if (!secret) {
+ log.warn("BREVO_WEBHOOK_SECRET is not configured, rejecting webhook");
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: "error.unconfigured",
+ status: 503,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Webhook secret is not configured",
+ });
+ return NextResponse.json({ error: "Webhook secret is not configured" }, { status: 503 });
+ }
+
+ const signature = req.headers.get("x-webhook-signature");
+ if (!verifyWebhookSignature(rawBody, signature, secret)) {
+ log.warn("Invalid webhook signature for brevo webhook");
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: "error.invalid_signature",
+ status: 401,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Invalid webhook signature",
+ });
+ return NextResponse.json({ error: "Invalid webhook signature" }, { status: 401 });
+ }
+
+ const parsedBody = brevoWebhookBodySchema.safeParse(parseJsonBody(rawBody));
+ if (!parsedBody.success) {
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: "error.invalid_body",
+ status: 400,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Invalid request body",
+ });
+ return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+ }
+
+ const body = parsedBody.data;
+ const triggerEvent = body.triggerEvent || body.event || "UNKNOWN";
+ triggerEventName = triggerEvent;
+ const eventData = body.payload || body.data || body;
+
+ const brevo = new BrevoService();
+ if (!brevo.isConfigured()) {
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: triggerEventName,
+ status: 500,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Brevo API key is not configured",
+ });
+ return NextResponse.json({ error: "Brevo API key is not configured" }, { status: 500 });
+ }
+
+ const attendees: Array<{ name?: string; email: string; timeZone?: string }> = eventData.attendees || [];
+ const meetingTitle = eventData.eventTitle || eventData.title || "Meeting";
+ const meetingStart = eventData.startTime;
+ const meetingStatus =
+ triggerEvent === "BOOKING_CANCELLED"
+ ? "CANCELLED"
+ : triggerEvent === "BOOKING_RESCHEDULED"
+ ? "RESCHEDULED"
+ : eventData.status || "ACCEPTED";
+
+ const syncResults: Array<{ email: string; synced: boolean }> = [];
+
+ // Sync each attendee into Brevo CRM
+ for (const attendee of attendees) {
+ if (attendee.email) {
+ const contactRes = await brevo.upsertContact({
+ email: attendee.email,
+ name: attendee.name,
+ meetingTitle,
+ meetingStart,
+ meetingStatus,
+ timeZone: attendee.timeZone,
+ });
+
+ const eventName =
+ triggerEvent === "BOOKING_CANCELLED"
+ ? "meeting_cancelled"
+ : triggerEvent === "BOOKING_RESCHEDULED"
+ ? "meeting_rescheduled"
+ : "meeting_booked";
+
+ const eventRes = await brevo.trackEvent({
+ eventName,
+ email: attendee.email,
+ properties: {
+ meeting_title: meetingTitle,
+ meeting_start: meetingStart,
+ status: meetingStatus,
+ },
+ });
+
+ syncResults.push({ email: attendee.email, synced: contactRes.success && eventRes.success });
+
+ if (!contactRes.success || !eventRes.success) {
+ log.error("Brevo sync failed for attendee", {
+ email: attendee.email,
+ contactError: contactRes.error,
+ eventError: eventRes.error,
+ });
+ }
+ }
+ }
+
+ const allSynced = syncResults.every((result) => result.synced);
+ const summary = `Synced ${syncResults.length} attendee(s) for event: ${triggerEventName}`;
+
+ if (allSynced) {
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: triggerEventName,
+ status: 200,
+ latencyMs: Date.now() - startTime,
+ success: true,
+ summary,
+ });
+ return NextResponse.json({
+ success: true,
+ partial: false,
+ triggerEvent,
+ syncedAttendees: syncResults.length,
+ results: syncResults,
+ });
+ }
+
+ if (syncResults.length > 0 && syncResults.every((result) => !result.synced)) {
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: triggerEventName,
+ status: 502,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ summary,
+ error: "All attendee syncs failed",
+ });
+ log.error("Brevo webhook sync failed for all attendees", { triggerEvent: triggerEventName });
+ return NextResponse.json({ success: false, error: "Failed to sync booking to Brevo" }, { status: 502 });
+ }
+
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: triggerEventName,
+ status: 200,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ summary,
+ });
+ log.warn("Brevo webhook sync partially failed", { triggerEvent: triggerEventName });
+ return NextResponse.json({
+ success: false,
+ partial: true,
+ triggerEvent,
+ syncedAttendees: syncResults.filter((result) => result.synced).length,
+ results: syncResults,
+ });
+ } catch (error) {
+ log.error("Brevo webhook processing failed", error);
+ webhookMonitor.recordDelivery({
+ source: "brevo",
+ event: triggerEventName,
+ status: 500,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Internal Server Error",
+ });
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts
new file mode 100644
index 00000000000..2416b593d7f
--- /dev/null
+++ b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts
@@ -0,0 +1,202 @@
+import { createHmac } from "node:crypto";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockRecordDelivery = vi.hoisted(() => vi.fn());
+const mockMethods = {
+ isConfigured: vi.fn(),
+ syncBookingEvent: vi.fn(),
+};
+
+const WEBHOOK_SECRET = "crove-crm-webhook-test-secret";
+const SIGNATURE_HEADER = "x-webhook-signature";
+
+vi.mock("@calcom/features/crove-crm/croveCrmService", () => {
+ return {
+ CroveCrmService: class MockCroveCrmService {
+ isConfigured() {
+ return mockMethods.isConfigured();
+ }
+ syncBookingEvent(args: unknown) {
+ return mockMethods.syncBookingEvent(args);
+ }
+ },
+ };
+});
+
+vi.mock("@calcom/lib/webhookMonitor", () => ({
+ webhookMonitor: {
+ recordDelivery: mockRecordDelivery,
+ },
+}));
+
+vi.mock("next/server", () => {
+ class MockNextResponse {
+ body: unknown;
+ status: number;
+ headers: Headers;
+
+ constructor(body: unknown, init?: { status?: number; headers?: Record }) {
+ this.body = body;
+ this.status = init?.status ?? 200;
+ this.headers = new Headers(init?.headers ?? {});
+ }
+
+ static json(data: unknown, init?: { status?: number; headers?: Record }) {
+ return {
+ status: init?.status ?? 200,
+ headers: new Headers(init?.headers ?? {}),
+ json: async () => data,
+ };
+ }
+ }
+
+ return {
+ NextResponse: MockNextResponse,
+ };
+});
+
+function createSignedRequest(body: string, signature?: string) {
+ const headers = new Headers();
+ if (signature !== undefined) {
+ headers.set(SIGNATURE_HEADER, signature);
+ }
+ return {
+ text: async () => body,
+ headers,
+ } as unknown as import("next/server").NextRequest;
+}
+
+function signBody(body: string, secret: string = WEBHOOK_SECRET): string {
+ return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`;
+}
+
+function createBookingBody(attendeeCount = 1): string {
+ return JSON.stringify({
+ triggerEvent: "BOOKING_CREATED",
+ payload: {
+ uid: "booking_123",
+ eventTitle: "Consultation 30m",
+ attendees: Array.from({ length: attendeeCount }, (_, index) => ({
+ email: `client-${index}@example.com`,
+ name: `Client ${index}`,
+ })),
+ },
+ });
+}
+
+describe("POST /api/webhooks/crove-crm", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.CROVE_CRM_WEBHOOK_SECRET = WEBHOOK_SECRET;
+ mockMethods.isConfigured.mockReturnValue(true);
+ mockMethods.syncBookingEvent.mockResolvedValue({
+ success: true,
+ syncedContacts: 1,
+ results: [{ success: true, contactId: "c_1", activityId: "a_1" }],
+ });
+ });
+
+ afterEach(() => {
+ delete process.env.CROVE_CRM_WEBHOOK_SECRET;
+ });
+
+ it("should return 503 and record a failed delivery when the webhook secret is unset", async () => {
+ delete process.env.CROVE_CRM_WEBHOOK_SECRET;
+ const { POST } = await import("../route");
+ const body = createBookingBody();
+ const res = await POST(createSignedRequest(body, signBody(body, "ignored")));
+
+ expect(res.status).toBe(503);
+ const json = await res.json();
+ expect(json.error).toBe("Webhook secret is not configured");
+ expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled();
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 503, success: false }));
+ });
+
+ it("should return 401 when the signature header is missing", async () => {
+ const { POST } = await import("../route");
+ const res = await POST(createSignedRequest(createBookingBody()));
+
+ expect(res.status).toBe(401);
+ const json = await res.json();
+ expect(json.error).toBe("Invalid webhook signature");
+ expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled();
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 401, success: false }));
+ });
+
+ it("should return 401 when the signature does not match the body", async () => {
+ const { POST } = await import("../route");
+ const body = createBookingBody();
+ const res = await POST(createSignedRequest(body, signBody(`${body}tampered`)));
+
+ expect(res.status).toBe(401);
+ expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled();
+ });
+
+ it("should return 400 when the attendees array exceeds 50 items", async () => {
+ const { POST } = await import("../route");
+ const body = createBookingBody(51);
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(400);
+ expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled();
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 400, success: false }));
+ });
+
+ it("should return 400 when an attendee has an invalid email", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify({
+ triggerEvent: "BOOKING_CREATED",
+ payload: { uid: "booking_123", attendees: [{ email: "not-an-email" }] },
+ });
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(400);
+ expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled();
+ });
+
+ it("should return 500 if Crove CRM is not configured", async () => {
+ mockMethods.isConfigured.mockReturnValue(false);
+ const { POST } = await import("../route");
+ const body = createBookingBody();
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(500);
+ const json = await res.json();
+ expect(json.error).toBe("Crove CRM API key is not configured");
+ });
+
+ it("should sync booking event and return 200 OK when configured and signed", async () => {
+ const { POST } = await import("../route");
+ const body = createBookingBody();
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.success).toBe(true);
+ expect(json.syncedContacts).toBe(1);
+ expect(mockMethods.syncBookingEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ triggerEvent: "BOOKING_CREATED",
+ payload: expect.objectContaining({ uid: "booking_123" }),
+ })
+ );
+ });
+
+ it("should not echo raw upstream errors to the client when the sync fails", async () => {
+ mockMethods.syncBookingEvent.mockResolvedValue({
+ success: false,
+ syncedContacts: 0,
+ results: [{ success: false, error: "Crove CRM API returned status 500" }],
+ });
+ const { POST } = await import("../route");
+ const body = createBookingBody();
+ const res = await POST(createSignedRequest(body, signBody(body)));
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.success).toBe(false);
+ expect(JSON.stringify(json)).not.toContain("Crove CRM API returned status 500");
+ expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 200, success: false }));
+ });
+});
diff --git a/apps/web/app/api/webhooks/crove-crm/route.ts b/apps/web/app/api/webhooks/crove-crm/route.ts
new file mode 100644
index 00000000000..7ed6426fe14
--- /dev/null
+++ b/apps/web/app/api/webhooks/crove-crm/route.ts
@@ -0,0 +1,179 @@
+import { CroveCrmService } from "@calcom/features/crove-crm/croveCrmService";
+import logger from "@calcom/lib/logger";
+import { verifyWebhookSignature } from "@calcom/lib/webhook-signature";
+import { webhookMonitor } from "@calcom/lib/webhookMonitor";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+const log = logger.getSubLogger({ prefix: ["webhook", "crove-crm"] });
+
+const croveCrmAttendeeSchema = z.object({
+ email: z.string().email(),
+ name: z.string().optional(),
+ timeZone: z.string().optional(),
+ phoneNumber: z.string().optional(),
+});
+
+const croveCrmEventDataSchema = z
+ .object({
+ uid: z.string().optional(),
+ title: z.string().optional(),
+ eventTitle: z.string().optional(),
+ startTime: z.string().optional(),
+ endTime: z.string().optional(),
+ status: z.string().optional(),
+ organizer: z
+ .object({
+ email: z.string(),
+ name: z.string().optional(),
+ })
+ .optional(),
+ attendees: z.array(croveCrmAttendeeSchema).max(50).optional(),
+ teamId: z.union([z.string(), z.number()]).optional(),
+ organizationId: z.union([z.string(), z.number()]).optional(),
+ metadata: z.record(z.unknown()).optional(),
+ })
+ .passthrough();
+
+const croveCrmWebhookBodySchema = croveCrmEventDataSchema.extend({
+ triggerEvent: z.string().max(100).optional(),
+ event: z.string().max(100).optional(),
+ payload: croveCrmEventDataSchema.optional(),
+ data: croveCrmEventDataSchema.optional(),
+});
+
+function parseJsonBody(rawBody: string): unknown {
+ try {
+ return JSON.parse(rawBody);
+ } catch {
+ return null;
+ }
+}
+
+export async function POST(req: NextRequest) {
+ const startTime = Date.now();
+ let triggerEventName = "unknown";
+
+ try {
+ const rawBody = await req.text();
+ if (!rawBody) {
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: "error.empty_body",
+ status: 400,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Empty request body",
+ });
+ return NextResponse.json({ error: "Empty request body" }, { status: 400 });
+ }
+
+ const secret = process.env.CROVE_CRM_WEBHOOK_SECRET;
+ if (!secret) {
+ log.warn("CROVE_CRM_WEBHOOK_SECRET is not configured, rejecting webhook");
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: "error.unconfigured",
+ status: 503,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Webhook secret is not configured",
+ });
+ return NextResponse.json({ error: "Webhook secret is not configured" }, { status: 503 });
+ }
+
+ const signature = req.headers.get("x-webhook-signature");
+ if (!verifyWebhookSignature(rawBody, signature, secret)) {
+ log.warn("Invalid webhook signature for crove-crm webhook");
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: "error.invalid_signature",
+ status: 401,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Invalid webhook signature",
+ });
+ return NextResponse.json({ error: "Invalid webhook signature" }, { status: 401 });
+ }
+
+ const parsedBody = croveCrmWebhookBodySchema.safeParse(parseJsonBody(rawBody));
+ if (!parsedBody.success) {
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: "error.invalid_body",
+ status: 400,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Invalid request body",
+ });
+ return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+ }
+
+ const body = parsedBody.data;
+ const triggerEvent = body.triggerEvent || body.event || "BOOKING_CREATED";
+ triggerEventName = triggerEvent;
+ const eventData = body.payload || body.data || body;
+
+ const crm = new CroveCrmService();
+ if (!crm.isConfigured()) {
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: triggerEventName,
+ status: 500,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Crove CRM API key is not configured",
+ });
+ return NextResponse.json({ error: "Crove CRM API key is not configured" }, { status: 500 });
+ }
+
+ const syncResult = await crm.syncBookingEvent({
+ triggerEvent,
+ payload: eventData,
+ });
+
+ if (!syncResult.success) {
+ log.error("Crove CRM sync reported failure", {
+ triggerEvent: triggerEventName,
+ syncedContacts: syncResult.syncedContacts,
+ totalResults: syncResult.results.length,
+ });
+ }
+
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: triggerEventName,
+ status: 200,
+ latencyMs: Date.now() - startTime,
+ success: syncResult.success,
+ summary: `Synced ${syncResult.syncedContacts} contact(s) and activities into Crove CRM`,
+ });
+
+ return NextResponse.json(
+ {
+ success: syncResult.success,
+ triggerEvent,
+ syncedContacts: syncResult.syncedContacts,
+ // Raw upstream errors are logged server-side only, never echoed to the client
+ results: syncResult.results.map(({ success, contactId, activityId }) => ({
+ success,
+ contactId,
+ activityId,
+ })),
+ },
+ { status: 200 }
+ );
+ } catch (error) {
+ log.error("Crove CRM webhook processing failed", error);
+ webhookMonitor.recordDelivery({
+ source: "crove-crm",
+ event: triggerEventName,
+ status: 500,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Internal Server Error",
+ });
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts b/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts
new file mode 100644
index 00000000000..2eccb64a021
--- /dev/null
+++ b/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts
@@ -0,0 +1,381 @@
+import { createHmac } from "node:crypto";
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+const mockPrisma = {
+ team: {
+ findFirst: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ user: {
+ findFirst: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ membership: {
+ upsert: vi.fn(),
+ deleteMany: vi.fn(),
+ },
+ profile: {
+ upsert: vi.fn(),
+ },
+ $transaction: vi.fn(),
+};
+
+// The route resolves users with findUnique (indexed, canonical-lowercase email) while the
+// existing per-test mocks configure findFirst; delegate so both names return the same stub.
+mockPrisma.user.findUnique.mockImplementation(mockPrisma.user.findFirst);
+mockPrisma.$transaction.mockImplementation((fn: (tx: typeof mockPrisma) => unknown) => fn(mockPrisma));
+
+vi.mock("@calcom/prisma", () => ({
+ default: mockPrisma,
+ prisma: mockPrisma,
+}));
+
+vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
+ ProfileRepository: {
+ generateProfileUid: vi.fn().mockReturnValue("mock-profile-uid-123"),
+ },
+}));
+
+vi.mock("next/server", () => {
+ class MockNextResponse {
+ body: unknown;
+ status: number;
+ headers: Headers;
+
+ constructor(body: unknown, init?: { status?: number; headers?: Record }) {
+ this.body = body;
+ this.status = init?.status ?? 200;
+ this.headers = new Headers(init?.headers ?? {});
+ }
+
+ static json(data: unknown, init?: { status?: number; headers?: Record }) {
+ return {
+ status: init?.status ?? 200,
+ headers: new Headers(init?.headers ?? {}),
+ json: async () => data,
+ };
+ }
+ }
+
+ return {
+ NextResponse: MockNextResponse,
+ };
+});
+
+function createMockRequest(body: string, headers: Record = {}) {
+ const headerMap = new Map();
+ for (const [key, value] of Object.entries(headers)) {
+ headerMap.set(key.toLowerCase(), value);
+ }
+
+ return {
+ text: async () => body,
+ headers: {
+ get: (name: string) => headerMap.get(name.toLowerCase()) || null,
+ },
+ } as unknown as import("next/server").NextRequest;
+}
+
+function generateSignature(body: string, secret: string): string {
+ return createHmac("sha256", secret).update(body, "utf8").digest("hex");
+}
+
+describe("/api/webhooks/dos-org-sync", () => {
+ const SECRET = "test-webhook-secret-123456";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.CROVE_CAL_DOS_WEBHOOK_SECRET = SECRET;
+ });
+
+ test("OPTIONS should return 204 with CORS headers", async () => {
+ const { OPTIONS } = await import("../route");
+ const response = await OPTIONS();
+ expect(response.status).toBe(204);
+ expect(response.headers.get("access-control-allow-origin")).toBe("*");
+ expect(response.headers.get("access-control-allow-methods")).toContain("POST");
+ });
+
+ test("POST should return 500 when webhook secret is missing", async () => {
+ delete process.env.CROVE_CAL_DOS_WEBHOOK_SECRET;
+ delete process.env.DOS_SYNC_WEBHOOK_SECRET;
+ delete process.env.OIDC_CLIENT_SECRET;
+
+ const { POST } = await import("../route");
+ const body = JSON.stringify({ event: "test.ping" });
+ const req = createMockRequest(body, { "x-dos-signature": "dummy" });
+
+ const res = await POST(req);
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.error).toBe("Webhook secret is not configured");
+ });
+
+ test("POST should return 401 when signature is missing or invalid", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify({ event: "test.ping" });
+ const req = createMockRequest(body, { "x-dos-signature": "invalid-sig" });
+
+ const res = await POST(req);
+ expect(res.status).toBe(401);
+ const data = await res.json();
+ expect(data.error).toBe("Invalid or missing signature");
+ });
+
+ test("POST should return 200 pong for test.ping event", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify({ event: "test.ping", timestamp: new Date().toISOString() });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": `sha256=${sig}` });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.message).toBe("pong");
+ });
+
+ test("POST should return 400 when org_id is missing for business events", async () => {
+ const { POST } = await import("../route");
+ const body = JSON.stringify({
+ event: "organization.created",
+ timestamp: new Date().toISOString(),
+ data: {},
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe("Missing org_id in payload");
+ });
+
+ test("POST organization.created should create a new Team organization", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValue(null);
+ mockPrisma.team.create.mockResolvedValue({ id: 100, name: "Acme Corp", metadata: { dosOrgId: "org-1" } });
+
+ const body = JSON.stringify({
+ event: "organization.created",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ org_name: "Acme Corp",
+ org_slug: "acme-corp",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.team.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ name: "Acme Corp",
+ isOrganization: true,
+ metadata: { dosOrgId: "org-1" },
+ }),
+ })
+ );
+ });
+
+ test("POST organization.updated should update existing team", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValue({ id: 100, metadata: { dosOrgId: "org-1" } });
+ mockPrisma.team.update.mockResolvedValue({ id: 100, name: "Acme Corp Updated" });
+
+ const body = JSON.stringify({
+ event: "org.updated",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ org_name: "Acme Corp Updated",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.team.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 100 },
+ data: expect.objectContaining({
+ name: "Acme Corp Updated",
+ }),
+ })
+ );
+ });
+
+ test("POST organization.member_added should upsert user, membership and profile", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValue({ id: 100 });
+ mockPrisma.user.findFirst.mockResolvedValue({ id: 200, email: "member@acme.com", username: "member" });
+ mockPrisma.membership.upsert.mockResolvedValue({});
+ mockPrisma.profile.upsert.mockResolvedValue({});
+
+ const body = JSON.stringify({
+ event: "organization.member_added",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ org_name: "Acme Corp",
+ user_email: "member@acme.com",
+ user_name: "Member Name",
+ role: "ADMIN",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.membership.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ userId_teamId: {
+ userId: 200,
+ teamId: 100,
+ },
+ },
+ })
+ );
+ expect(mockPrisma.profile.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ userId_organizationId: {
+ userId: 200,
+ organizationId: 100,
+ },
+ },
+ })
+ );
+ });
+
+ test("POST organization.member_removed should delete user membership", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValue({ id: 100 });
+ mockPrisma.user.findFirst.mockResolvedValue({ id: 200, email: "member@acme.com" });
+ mockPrisma.membership.deleteMany.mockResolvedValue({ count: 1 });
+
+ const body = JSON.stringify({
+ event: "org.member_removed",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ org_name: "Acme Corp",
+ user_email: "member@acme.com",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.membership.deleteMany).toHaveBeenCalledWith({
+ where: {
+ userId: 200,
+ teamId: 100,
+ },
+ });
+ });
+
+ test("POST organization.deleted should delete the Team", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValue({ id: 100 });
+ mockPrisma.team.delete.mockResolvedValue({ id: 100 });
+
+ const body = JSON.stringify({
+ event: "organization.deleted",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ org_name: "Acme Corp",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.team.delete).toHaveBeenCalledWith({
+ where: { id: 100 },
+ });
+ });
+
+ test("POST team.created should create child team with parent organization", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 100, isOrganization: true }) // parent org
+ .mockResolvedValueOnce(null) // child team not found
+ .mockResolvedValueOnce(null); // slug check
+ mockPrisma.team.create.mockResolvedValue({ id: 200, name: "Customer Support", parentId: 100 });
+
+ const body = JSON.stringify({
+ event: "team.created",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ team_id: "team-101",
+ team_name: "Customer Support",
+ team_slug: "customer-support",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.team.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ name: "Customer Support",
+ isOrganization: false,
+ parentId: 100,
+ metadata: { dosTeamId: "team-101", dosOrgId: "org-1" },
+ }),
+ })
+ );
+ });
+
+ test("POST team.member_added should add member to child team", async () => {
+ const { POST } = await import("../route");
+ mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 200, isOrganization: false }); // child team
+ mockPrisma.user.findFirst.mockResolvedValueOnce({ id: 50, email: "agent@acme.com" });
+ mockPrisma.membership.upsert.mockResolvedValue({});
+
+ const body = JSON.stringify({
+ event: "team.member_added",
+ timestamp: new Date().toISOString(),
+ data: {
+ org_id: "org-1",
+ team_id: "team-101",
+ user_email: "agent@acme.com",
+ role: "LEAD",
+ },
+ });
+ const sig = generateSignature(body, SECRET);
+ const req = createMockRequest(body, { "x-dos-signature": sig });
+
+ const res = await POST(req);
+ expect(res.status).toBe(200);
+ expect(mockPrisma.membership.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ userId_teamId: {
+ userId: 50,
+ teamId: 200,
+ },
+ },
+ create: expect.objectContaining({
+ role: "ADMIN",
+ }),
+ })
+ );
+ });
+});
diff --git a/apps/web/app/api/webhooks/dos-org-sync/route.ts b/apps/web/app/api/webhooks/dos-org-sync/route.ts
new file mode 100644
index 00000000000..fc62e363bf8
--- /dev/null
+++ b/apps/web/app/api/webhooks/dos-org-sync/route.ts
@@ -0,0 +1,651 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
+import { webhookMonitor } from "@calcom/lib/webhookMonitor";
+import slugify from "@calcom/lib/slugify";
+import prisma from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+interface DosWebhookPayload {
+ event:
+ | "test.ping"
+ | "ping"
+ | "organization.created"
+ | "org.created"
+ | "organization.updated"
+ | "org.updated"
+ | "organization.deleted"
+ | "org.deleted"
+ | "organization.member_added"
+ | "organization.member.added"
+ | "org.member_added"
+ | "organization.member_removed"
+ | "organization.member.removed"
+ | "org.member_removed"
+ | "team.created"
+ | "team.updated"
+ | "team.deleted"
+ | "team.member_added"
+ | "team.member.added"
+ | "team.member_removed"
+ | "team.member.removed"
+ | "user.updated";
+ timestamp: string;
+ data?: {
+ org_id?: string;
+ org_name?: string;
+ org_slug?: string;
+ team_id?: string | number;
+ team_name?: string;
+ team_slug?: string;
+ name?: string;
+ slug?: string;
+ user_id?: string;
+ user_email?: string;
+ user_name?: string;
+ role?: "OWNER" | "ADMIN" | "LEAD" | "MEMBER" | string;
+ [key: string]: unknown;
+ };
+}
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers":
+ "Content-Type, Authorization, x-dos-signature, x-dos-event, x-dos-delivery, x-dos-timestamp",
+};
+
+export async function OPTIONS() {
+ return new NextResponse(null, {
+ status: 204,
+ headers: corsHeaders,
+ });
+}
+
+function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean {
+ if (!signatureHeader || !secret) return false;
+ const signature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : signatureHeader;
+ const expectedSignature = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
+
+ const sigBuffer = Buffer.from(signature, "hex");
+ const expectedBuffer = Buffer.from(expectedSignature, "hex");
+
+ if (sigBuffer.length !== expectedBuffer.length || sigBuffer.length === 0) {
+ return false;
+ }
+
+ return timingSafeEqual(sigBuffer, expectedBuffer);
+}
+
+const REPLAY_WINDOW_MS = 300_000;
+
+function isTimestampFresh(timestamp: string | null): boolean {
+ if (!timestamp) return false;
+ const asNumber = Number(timestamp);
+ const timestampMs = Number.isFinite(asNumber) ? (asNumber > 1e12 ? asNumber : asNumber * 1000) : Date.parse(timestamp);
+ if (!Number.isFinite(timestampMs)) return false;
+ return Math.abs(Date.now() - timestampMs) <= REPLAY_WINDOW_MS;
+}
+
+const MAX_TRACKED_DELIVERIES = 1000;
+const recentDeliveryIds = new Map();
+
+function isDuplicateDelivery(deliveryId: string): boolean {
+ if (recentDeliveryIds.has(deliveryId)) {
+ return true;
+ }
+ if (recentDeliveryIds.size >= MAX_TRACKED_DELIVERIES) {
+ // Map preserves insertion order, so the first key is the oldest entry
+ const oldest = recentDeliveryIds.keys().next();
+ if (!oldest.done && oldest.value !== undefined) {
+ recentDeliveryIds.delete(oldest.value);
+ }
+ }
+ recentDeliveryIds.set(deliveryId, Date.now());
+ return false;
+}
+
+export async function POST(req: NextRequest) {
+ const startTime = Date.now();
+ let eventName = "unknown";
+ try {
+ const rawBody = await req.text();
+ const signature = req.headers.get("x-dos-signature");
+ // Why: product-prefixed name matches the GCP Secret Manager convention shared
+ // with the other Crove apps (CROVE_SIGN_DOS_WEBHOOK_SECRET, CROVE_CRM_WEBHOOK_SECRET),
+ // so each product holds its own signing key and one leak cannot forge another's events.
+ // The unprefixed name is kept as a fallback for deployments that already set it.
+ const secret = process.env.CROVE_CAL_DOS_WEBHOOK_SECRET || process.env.DOS_SYNC_WEBHOOK_SECRET;
+
+ if (!secret) {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "error.unconfigured",
+ status: 500,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Webhook secret is not configured",
+ });
+ return NextResponse.json(
+ { error: "Webhook secret is not configured" },
+ { status: 500, headers: corsHeaders }
+ );
+ }
+
+ if (!signature || !verifyHmacSignature(rawBody, signature, secret)) {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "error.invalid_signature",
+ status: 401,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Invalid or missing signature",
+ });
+ return NextResponse.json(
+ { error: "Invalid or missing signature" },
+ { status: 401, headers: corsHeaders }
+ );
+ }
+
+ const deliveryId = req.headers.get("x-dos-delivery");
+ if (deliveryId && isDuplicateDelivery(deliveryId)) {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "error.duplicate_delivery",
+ status: 409,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: `Duplicate delivery id: ${deliveryId}`,
+ });
+ return NextResponse.json({ error: "Duplicate delivery" }, { status: 409, headers: corsHeaders });
+ }
+
+ const payload: DosWebhookPayload = JSON.parse(rawBody);
+ const { event, data } = payload;
+ eventName = event;
+
+ if (!isTimestampFresh(payload.timestamp || req.headers.get("x-dos-timestamp"))) {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: eventName,
+ status: 401,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Missing or stale timestamp (possible replay)",
+ });
+ return NextResponse.json(
+ { error: "Invalid or stale timestamp" },
+ { status: 401, headers: corsHeaders }
+ );
+ }
+
+ if (event === "test.ping" || event === "ping") {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: eventName,
+ status: 200,
+ latencyMs: Date.now() - startTime,
+ success: true,
+ summary: "Ping / Pong check",
+ });
+ return NextResponse.json(
+ { success: true, message: "pong", timestamp: new Date().toISOString() },
+ { status: 200, headers: corsHeaders }
+ );
+ }
+
+ if (!data?.org_id) {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: eventName,
+ status: 400,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: "Missing org_id in payload",
+ });
+ return NextResponse.json({ error: "Missing org_id in payload" }, { status: 400, headers: corsHeaders });
+ }
+
+ const orgId = String(data.org_id);
+ const orgName = data.org_name || "Default Organization";
+ const orgSlug = data.org_slug ? slugify(data.org_slug) : slugify(orgName);
+
+ switch (event) {
+ case "organization.created":
+ case "org.created":
+ case "organization.updated":
+ case "org.updated": {
+ let team = await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true, metadata: true },
+ });
+
+ if (!team) {
+ let uniqueSlug = orgSlug;
+ const existingSlugTeam = await prisma.team.findFirst({
+ where: { slug: uniqueSlug },
+ select: { id: true },
+ });
+
+ if (existingSlugTeam) {
+ uniqueSlug = `${orgSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ team = await prisma.team.create({
+ data: {
+ name: orgName,
+ slug: uniqueSlug,
+ isOrganization: true,
+ metadata: {
+ dosOrgId: orgId,
+ },
+ },
+ select: { id: true, metadata: true },
+ });
+ } else {
+ await prisma.team.update({
+ where: { id: team.id },
+ data: {
+ name: orgName,
+ metadata: {
+ ...(typeof team.metadata === "object" && team.metadata ? team.metadata : {}),
+ dosOrgId: orgId,
+ },
+ },
+ });
+ }
+ break;
+ }
+
+ case "organization.deleted":
+ case "org.deleted": {
+ const team = await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true },
+ });
+
+ if (team) {
+ await prisma.team.delete({
+ where: { id: team.id },
+ });
+ }
+ break;
+ }
+
+ case "organization.member_added":
+ case "organization.member.added":
+ case "org.member_added": {
+ // The case performs several dependent writes (org team, user, membership, profile,
+ // user organizationId); a transaction keeps them from landing half-applied.
+ await prisma.$transaction(async (tx) => {
+ let team = await tx.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true },
+ });
+
+ if (!team) {
+ let uniqueSlug = orgSlug;
+ const existingSlugTeam = await tx.team.findFirst({
+ where: { slug: uniqueSlug },
+ select: { id: true },
+ });
+
+ if (existingSlugTeam) {
+ uniqueSlug = `${orgSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ team = await tx.team.create({
+ data: {
+ name: orgName,
+ slug: uniqueSlug,
+ isOrganization: true,
+ metadata: {
+ dosOrgId: orgId,
+ },
+ },
+ select: { id: true },
+ });
+ }
+
+ if (data.user_email) {
+ const userEmail = data.user_email.toLowerCase().trim();
+ let user = await tx.user.findUnique({
+ where: { email: userEmail },
+ select: {
+ id: true,
+ email: true,
+ username: true,
+ organizationId: true,
+ },
+ });
+
+ if (!user) {
+ const newUsername =
+ slugify(data.user_name || userEmail.split("@")[0]) +
+ `-${Math.random().toString(36).substring(2, 6)}`;
+ user = await tx.user.create({
+ data: {
+ email: userEmail,
+ name: data.user_name || userEmail.split("@")[0],
+ username: newUsername,
+ emailVerified: new Date(),
+ organizationId: team.id,
+ },
+ select: {
+ id: true,
+ email: true,
+ username: true,
+ organizationId: true,
+ },
+ });
+ }
+
+ const rawRole = (data.role || "").toUpperCase();
+ const membershipRole =
+ rawRole === "OWNER"
+ ? MembershipRole.OWNER
+ : rawRole === "ADMIN"
+ ? MembershipRole.ADMIN
+ : MembershipRole.MEMBER;
+
+ await tx.membership.upsert({
+ where: {
+ userId_teamId: {
+ userId: user.id,
+ teamId: team.id,
+ },
+ },
+ create: {
+ userId: user.id,
+ teamId: team.id,
+ role: membershipRole,
+ accepted: true,
+ },
+ update: {
+ role: membershipRole,
+ accepted: true,
+ },
+ });
+
+ const orgUsername = user.username || user.email.split("@")[0];
+ await tx.profile.upsert({
+ create: {
+ uid: ProfileRepository.generateProfileUid(),
+ userId: user.id,
+ organizationId: team.id,
+ username: orgUsername,
+ },
+ update: {
+ username: orgUsername,
+ },
+ where: {
+ userId_organizationId: {
+ userId: user.id,
+ organizationId: team.id,
+ },
+ },
+ });
+
+ if (!user.organizationId) {
+ await tx.user.update({
+ where: { id: user.id },
+ data: { organizationId: team.id },
+ });
+ }
+ }
+ });
+ break;
+ }
+
+ case "organization.member_removed":
+ case "organization.member.removed":
+ case "org.member_removed": {
+ const team = await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true },
+ });
+
+ if (team && data.user_email) {
+ const user = await prisma.user.findUnique({
+ where: { email: data.user_email.toLowerCase().trim() },
+ select: { id: true },
+ });
+
+ if (user) {
+ await prisma.membership.deleteMany({
+ where: {
+ userId: user.id,
+ teamId: team.id,
+ },
+ });
+ }
+ }
+ break;
+ }
+
+ case "team.created":
+ case "team.updated": {
+ const teamId = data.team_id ? String(data.team_id) : undefined;
+ const teamName = data.team_name || data.name || "Default Team";
+ const teamSlug = data.team_slug || data.slug ? slugify(String(data.team_slug || data.slug)) : slugify(teamName);
+
+ const parentOrg = await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true },
+ });
+
+ let childTeam = teamId
+ ? await prisma.team.findFirst({
+ where: {
+ isOrganization: false,
+ metadata: { path: ["dosTeamId"], equals: teamId },
+ },
+ select: { id: true, parentId: true },
+ })
+ : await prisma.team.findFirst({
+ where: {
+ isOrganization: false,
+ slug: teamSlug,
+ ...(parentOrg ? { parentId: parentOrg.id } : {}),
+ },
+ select: { id: true, parentId: true },
+ });
+
+ if (!childTeam) {
+ let uniqueSlug = teamSlug;
+ const existingSlugTeam = await prisma.team.findFirst({
+ where: { slug: uniqueSlug, ...(parentOrg ? { parentId: parentOrg.id } : {}) },
+ select: { id: true },
+ });
+
+ if (existingSlugTeam) {
+ uniqueSlug = `${teamSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ childTeam = await prisma.team.create({
+ data: {
+ name: teamName,
+ slug: uniqueSlug,
+ isOrganization: false,
+ parentId: parentOrg?.id || null,
+ metadata: {
+ dosTeamId: teamId,
+ dosOrgId: orgId,
+ },
+ },
+ select: { id: true, parentId: true },
+ });
+ } else {
+ await prisma.team.update({
+ where: { id: childTeam.id },
+ data: {
+ name: teamName,
+ parentId: parentOrg?.id || childTeam.parentId,
+ metadata: {
+ dosTeamId: teamId,
+ dosOrgId: orgId,
+ },
+ },
+ });
+ }
+ break;
+ }
+
+ case "team.deleted": {
+ const teamId = data.team_id ? String(data.team_id) : undefined;
+ if (teamId) {
+ // The team must belong to the event's org, otherwise a team_id from another
+ // org could be deleted by replaying/forging an event for a different org_id.
+ const parentOrg = await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true },
+ });
+
+ if (parentOrg) {
+ await prisma.team.deleteMany({
+ where: {
+ isOrganization: false,
+ parentId: parentOrg.id,
+ metadata: { path: ["dosTeamId"], equals: teamId },
+ },
+ });
+ }
+ }
+ break;
+ }
+
+ case "team.member_added":
+ case "team.member.added": {
+ const teamId = data.team_id ? String(data.team_id) : undefined;
+ let childTeam = teamId
+ ? await prisma.team.findFirst({
+ where: {
+ isOrganization: false,
+ metadata: { path: ["dosTeamId"], equals: teamId },
+ },
+ select: { id: true },
+ })
+ : null;
+
+ if (childTeam && data.user_email) {
+ const user = await prisma.user.findUnique({
+ where: { email: data.user_email.toLowerCase().trim() },
+ select: { id: true },
+ });
+
+ if (user) {
+ const rawRole = (data.role || "").toUpperCase();
+ const teamRole =
+ rawRole === "LEAD" || rawRole === "ADMIN" || rawRole === "OWNER"
+ ? MembershipRole.ADMIN
+ : MembershipRole.MEMBER;
+
+ await prisma.membership.upsert({
+ where: {
+ userId_teamId: {
+ userId: user.id,
+ teamId: childTeam.id,
+ },
+ },
+ create: {
+ userId: user.id,
+ teamId: childTeam.id,
+ role: teamRole,
+ accepted: true,
+ },
+ update: {
+ role: teamRole,
+ accepted: true,
+ },
+ });
+ }
+ }
+ break;
+ }
+
+ case "team.member_removed":
+ case "team.member.removed": {
+ const teamId = data.team_id ? String(data.team_id) : undefined;
+ if (teamId && data.user_email) {
+ const childTeam = await prisma.team.findFirst({
+ where: {
+ isOrganization: false,
+ metadata: { path: ["dosTeamId"], equals: teamId },
+ },
+ select: { id: true },
+ });
+
+ const user = await prisma.user.findUnique({
+ where: { email: data.user_email.toLowerCase().trim() },
+ select: { id: true },
+ });
+
+ if (childTeam && user) {
+ await prisma.membership.deleteMany({
+ where: {
+ userId: user.id,
+ teamId: childTeam.id,
+ },
+ });
+ }
+ }
+ break;
+ }
+
+ default:
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: eventName,
+ status: 200,
+ latencyMs: Date.now() - startTime,
+ success: true,
+ summary: `Ignored event: ${event}`,
+ });
+ return NextResponse.json(
+ { message: `Ignored event: ${event}` },
+ { status: 200, headers: corsHeaders }
+ );
+ }
+
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: eventName,
+ status: 200,
+ latencyMs: Date.now() - startTime,
+ success: true,
+ summary: `Successfully processed ${eventName}`,
+ });
+
+ return NextResponse.json({ success: true, event }, { status: 200, headers: corsHeaders });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Internal Server Error";
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: eventName,
+ status: 500,
+ latencyMs: Date.now() - startTime,
+ success: false,
+ error: message,
+ });
+ return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
+ }
+}
diff --git a/apps/web/app/api/webhooks/health/__tests__/route.test.ts b/apps/web/app/api/webhooks/health/__tests__/route.test.ts
new file mode 100644
index 00000000000..9d4ff5da228
--- /dev/null
+++ b/apps/web/app/api/webhooks/health/__tests__/route.test.ts
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Session } from "next-auth";
+import { NextRequest } from "next/server";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { webhookMonitor } from "@calcom/lib/webhookMonitor";
+
+import { GET, POST } from "../route";
+
+vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
+ getServerSession: vi.fn(),
+}));
+
+// next/headers must be mocked because the jsdom test env has no request scope
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(() => ({ getAll: () => [] })),
+ headers: vi.fn(() => new Headers()),
+}));
+
+const mockedGetServerSession = vi.mocked(getServerSession);
+
+function makeTestSession(role: "ADMIN" | "USER"): Session {
+ return {
+ expires: "2099-01-01T00:00:00.000Z",
+ hasValidLicense: true,
+ upId: "1",
+ user: {
+ id: 1,
+ uuid: "00000000-0000-0000-0000-000000000000",
+ role,
+ },
+ };
+}
+
+describe("API /api/webhooks/health Endpoint", () => {
+ beforeEach(() => {
+ webhookMonitor.reset();
+ mockedGetServerSession.mockReset();
+ });
+
+ it("should return 401 on GET when unauthenticated", async () => {
+ mockedGetServerSession.mockResolvedValue(null);
+
+ const req = new NextRequest("http://localhost:3000/api/webhooks/health");
+ const res = await GET(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(401);
+ const json = await res.json();
+ expect(json.message).toBe("Unauthorized");
+ expect(mockedGetServerSession).toHaveBeenCalledTimes(1);
+ });
+
+ it("should return 401 on POST when unauthenticated", async () => {
+ mockedGetServerSession.mockResolvedValue(null);
+
+ const req = new NextRequest("http://localhost:3000/api/webhooks/health", { method: "POST" });
+ const res = await POST(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(401);
+ });
+
+ it("should return metrics with 200 OK on GET when authenticated", async () => {
+ mockedGetServerSession.mockResolvedValue(makeTestSession("USER"));
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "test.ping",
+ status: 200,
+ latencyMs: 12,
+ success: true,
+ });
+
+ const req = new NextRequest("http://localhost:3000/api/webhooks/health");
+ const res = await GET(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.service).toBe("crove-cal-webhooks");
+ expect(json.totalEvents).toBe(1);
+ expect(json.successCount).toBe(1);
+ expect(json.status).toBe("healthy");
+ expect(json.recentDeliveries).toHaveLength(1);
+ });
+
+ it("should return 403 on POST for authenticated non-admin", async () => {
+ mockedGetServerSession.mockResolvedValue(makeTestSession("USER"));
+
+ const req = new NextRequest("http://localhost:3000/api/webhooks/health", { method: "POST" });
+ const res = await POST(req, { params: Promise.resolve({}) });
+
+ expect(res.status).toBe(403);
+ expect(webhookMonitor.getMetrics().totalEvents).toBe(0);
+ });
+
+ it("should trigger and record manual ping simulation on POST for admin", async () => {
+ mockedGetServerSession.mockResolvedValue(makeTestSession("ADMIN"));
+
+ const req = new NextRequest("http://localhost:3000/api/webhooks/health", {
+ method: "POST",
+ body: JSON.stringify({
+ source: "brevo",
+ event: "test.ping",
+ }),
+ });
+
+ const res = await POST(req, { params: Promise.resolve({}) });
+ expect(res.status).toBe(200);
+
+ const json = await res.json();
+ expect(json.success).toBe(true);
+ expect(json.delivery.source).toBe("brevo");
+ expect(json.delivery.event).toBe("test.ping");
+
+ const metrics = webhookMonitor.getMetrics();
+ expect(metrics.totalEvents).toBe(1);
+ });
+});
diff --git a/apps/web/app/api/webhooks/health/route.ts b/apps/web/app/api/webhooks/health/route.ts
new file mode 100644
index 00000000000..c7062ce7f6a
--- /dev/null
+++ b/apps/web/app/api/webhooks/health/route.ts
@@ -0,0 +1,98 @@
+import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
+import { cookies, headers } from "next/headers";
+import { type NextRequest, NextResponse } from "next/server";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { webhookMonitor } from "@calcom/lib/webhookMonitor";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, x-dos-signature",
+};
+
+export async function OPTIONS() {
+ return new NextResponse(null, {
+ status: 204,
+ headers: corsHeaders,
+ });
+}
+
+async function getMonitoringHandler() {
+ const session = await getServerSession({
+ req: buildLegacyRequest(await headers(), await cookies()),
+ });
+ if (!session?.user?.id) {
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
+ }
+
+ const metrics = webhookMonitor.getMetrics();
+ const statusCode = metrics.status === "failing" ? 503 : 200;
+
+ return NextResponse.json(
+ {
+ service: "crove-cal-webhooks",
+ timestamp: new Date().toISOString(),
+ ...metrics,
+ },
+ {
+ status: statusCode,
+ headers: {
+ ...corsHeaders,
+ "Cache-Control": "no-store, no-cache, must-revalidate",
+ },
+ }
+ );
+}
+
+async function postTriggerPingHandler(req: NextRequest) {
+ const session = await getServerSession({
+ req: buildLegacyRequest(await headers(), await cookies()),
+ });
+ if (!session?.user?.id) {
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
+ }
+ if (session.user.role !== "ADMIN") {
+ return NextResponse.json({ message: "Forbidden" }, { status: 403 });
+ }
+
+ const startTime = Date.now();
+ try {
+ const body = await req.json().catch(() => ({}));
+ const source = body.source || "dos-org-sync";
+ const event = body.event || "test.ping";
+
+ // Record test delivery in monitor
+ const delivery = webhookMonitor.recordDelivery({
+ source,
+ event,
+ status: 200,
+ latencyMs: Date.now() - startTime + 5,
+ success: true,
+ summary: `Manual test ping simulation for ${source}`,
+ });
+
+ return NextResponse.json(
+ {
+ success: true,
+ message: "Ping simulation recorded successfully",
+ delivery,
+ },
+ {
+ status: 200,
+ headers: corsHeaders,
+ }
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Internal error";
+ return NextResponse.json(
+ { success: false, error: message },
+ { status: 500, headers: corsHeaders }
+ );
+ }
+}
+
+export const GET = defaultResponderForAppDir(getMonitoringHandler);
+export const POST = defaultResponderForAppDir(postTriggerPingHandler);
diff --git a/apps/web/lib/__tests__/cronAuth.test.ts b/apps/web/lib/__tests__/cronAuth.test.ts
new file mode 100644
index 00000000000..50b9f34e45d
--- /dev/null
+++ b/apps/web/lib/__tests__/cronAuth.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/server", () => ({
+ NextResponse: {
+ json: (body: unknown, init?: { status?: number }) => ({
+ status: init?.status ?? 200,
+ body,
+ }),
+ },
+}));
+
+import { assertCronSecret } from "../cronAuth";
+
+function requestWith(headers: Record, url = "http://localhost/api/cron/test") {
+ return new Request(url, { headers });
+}
+
+describe("assertCronSecret", () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.stubEnv("CRON_SECRET", "test-cron-secret");
+ vi.stubEnv("CRON_API_KEY", "");
+ });
+
+ it("passes with a valid Bearer CRON_SECRET", () => {
+ const request = requestWith({ authorization: "Bearer test-cron-secret" });
+ expect(assertCronSecret(request)).toBeNull();
+ });
+
+ it("passes with a case-insensitive bearer scheme and header name", () => {
+ const headers = new Headers({ Authorization: "bearer test-cron-secret" });
+ const request = new Request("http://localhost/api/cron/test", { headers });
+ expect(request.headers.get("authorization")).toBe("bearer test-cron-secret");
+ expect(assertCronSecret(request)).toBeNull();
+ });
+
+ it("returns 401 with a wrong secret", () => {
+ const request = requestWith({ authorization: "Bearer wrong-secret" });
+ const response = assertCronSecret(request);
+ expect(response).not.toBeNull();
+ expect(response?.status).toBe(401);
+ });
+
+ it("returns 401 with a missing Authorization header", () => {
+ const response = assertCronSecret(requestWith({}));
+ expect(response).not.toBeNull();
+ expect(response?.status).toBe(401);
+ });
+
+ it("returns 401 when CRON_SECRET is unset, even if a bearer value is sent", () => {
+ vi.stubEnv("CRON_SECRET", "");
+ const request = requestWith({ authorization: "Bearer test-cron-secret" });
+ const response = assertCronSecret(request);
+ expect(response).not.toBeNull();
+ expect(response?.status).toBe(401);
+ });
+
+ it("passes with the legacy CRON_API_KEY as raw Authorization header", () => {
+ vi.stubEnv("CRON_API_KEY", "legacy-key");
+ const request = requestWith({ authorization: "legacy-key" });
+ expect(assertCronSecret(request)).toBeNull();
+ });
+
+ it("passes with the legacy CRON_API_KEY as ?apiKey= query parameter", () => {
+ vi.stubEnv("CRON_API_KEY", "legacy-key");
+ const request = requestWith({}, "http://localhost/api/cron/test?apiKey=legacy-key");
+ expect(assertCronSecret(request)).toBeNull();
+ });
+
+ it("returns 401 when CRON_API_KEY is empty and an empty apiKey query param is sent (bypass guard)", () => {
+ vi.stubEnv("CRON_API_KEY", "");
+ const request = requestWith({}, "http://localhost/api/cron/test?apiKey=");
+ const response = assertCronSecret(request);
+ expect(response).not.toBeNull();
+ expect(response?.status).toBe(401);
+ });
+});
diff --git a/apps/web/lib/cronAuth.ts b/apps/web/lib/cronAuth.ts
new file mode 100644
index 00000000000..19fc0673b33
--- /dev/null
+++ b/apps/web/lib/cronAuth.ts
@@ -0,0 +1,53 @@
+import { createHash, timingSafeEqual } from "node:crypto";
+import process from "node:process";
+import { NextResponse } from "next/server";
+
+/**
+ * Timing-safe string comparison.
+ *
+ * Both sides are hashed with SHA-256 before `timingSafeEqual` so the compared
+ * buffers always have the same length: `timingSafeEqual` throws on length
+ * mismatch, and an exception thrown/not-thrown would itself leak the secret
+ * length. Hashing first gives fixed-length digests and removes that leak.
+ */
+function secretsMatch(provided: string, expected: string | undefined): boolean {
+ if (!expected) {
+ // Fail closed when the secret is unset or empty (an empty expected value
+ // would otherwise match an empty provided value and bypass auth entirely).
+ return false;
+ }
+ const providedDigest = createHash("sha256").update(provided, "utf8").digest();
+ const expectedDigest = createHash("sha256").update(expected, "utf8").digest();
+ return timingSafeEqual(providedDigest, expectedDigest);
+}
+
+/**
+ * Shared authentication for /api/cron/* routes.
+ *
+ * Accepts:
+ * - `Authorization: Bearer ` (what Vercel Cron sends, see vercel.json crons)
+ * - legacy `CRON_API_KEY` either as the raw `Authorization` header value or as
+ * the `?apiKey=` query parameter (external schedulers / cron-tester.ts)
+ *
+ * Returns `null` when the request is authorized, otherwise a 401 NextResponse
+ * that the route should return immediately.
+ */
+export function assertCronSecret(request: Request): NextResponse | null {
+ // Header names are case-insensitive per the fetch spec; the "Bearer" scheme
+ // prefix is compared case-insensitively as well.
+ const authHeader = request.headers.get("authorization") ?? "";
+ const bearerValue = /^Bearer\s+(.+)$/i.exec(authHeader)?.[1] ?? "";
+ const rawHeaderValue = authHeader.trim();
+ const queryApiKey = new URL(request.url).searchParams.get("apiKey") ?? "";
+
+ const authorized =
+ secretsMatch(bearerValue, process.env.CRON_SECRET) ||
+ secretsMatch(rawHeaderValue, process.env.CRON_API_KEY) ||
+ secretsMatch(queryApiKey, process.env.CRON_API_KEY);
+
+ if (authorized) {
+ return null;
+ }
+
+ return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
+}
diff --git a/apps/web/modules/auth/hooks/useLastUsed.tsx b/apps/web/modules/auth/hooks/useLastUsed.tsx
index 93cef616eaf..9a39f6b9dfc 100644
--- a/apps/web/modules/auth/hooks/useLastUsed.tsx
+++ b/apps/web/modules/auth/hooks/useLastUsed.tsx
@@ -4,7 +4,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import classNames from "@calcom/ui/classNames";
-type LoginType = "saml" | "google" | "microsoft" | "credentials";
+type LoginType = "saml" | "google" | "microsoft" | "credentials" | "dos-id";
export function useLastUsed() {
const [lastUsed, setLastUsed] = useState();
diff --git a/apps/web/modules/auth/login-view.tsx b/apps/web/modules/auth/login-view.tsx
index 1f77f10c9cc..0d8e73d9dde 100644
--- a/apps/web/modules/auth/login-view.tsx
+++ b/apps/web/modules/auth/login-view.tsx
@@ -36,12 +36,21 @@ interface LoginValues {
csrfToken: string;
}
-const MicrosoftIcon = () => (
-
-);
+const MicrosoftIcon = () => ;
+
+const GoogleIcon = () => ;
-const GoogleIcon = () => (
-
+const DosIdIcon = () => (
+
);
function BackgroundGrid() {
@@ -105,6 +114,7 @@ export default function Login({
csrfToken,
isGoogleLoginEnabled,
isOutlookLoginEnabled,
+ isDosIdLoginEnabled,
totpEmail,
}: PageProps) {
const searchParams = useCompatSearchParams();
@@ -170,7 +180,8 @@ export default function Login({
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
};
- const showSocialLogin = isGoogleLoginEnabled || isOutlookLoginEnabled;
+ const isDosIdAuthEnabled = isDosIdLoginEnabled || process.env.NEXT_PUBLIC_DOS_ID_LOGIN_ENABLED === "true";
+ const showSocialLogin = isGoogleLoginEnabled || isOutlookLoginEnabled || isDosIdAuthEnabled;
const showSignupLink =
process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true" && searchParams?.get("register") !== "false";
@@ -183,7 +194,9 @@ export default function Login({
+ Minimize meeting no-shows with an instant text message 1 hour prior.
+
+
+ Use Template →
+
+
+
+ handleOpenPreset({
+ name: "Post-Meeting Thank You & Survey",
+ trigger: WorkflowTriggerEvents.AFTER_EVENT,
+ time: 30,
+ timeUnit: TimeUnit.MINUTE,
+ action: WorkflowActions.EMAIL_ATTENDEE,
+ subject: "Thank you for meeting today! - {EVENT_NAME}",
+ body: "Hi {ATTENDEE_NAME},\n\nThank you for taking the time to speak today! Please let us know if you have any questions or feedback.\n\nBest regards,\n{ORGANIZER_NAME}",
+ })
+ }
+ className="flex flex-col justify-between rounded-xl border border-subtle p-3.5 text-left transition hover:border-emphasis hover:bg-subtle">
+
+
+
+ Follow-Up & Feedback
+
+
+ Automatically follow up with materials or a survey 30 minutes after meeting ends.
+
+ Cách đọc: mỗi phát hiện có ID (CR/HI/MD/LO-##), Priority (mức độ rủi ro), Action order (thứ tự khắc phục khuyến nghị — mục "Kế hoạch khắc phục"). Cột Verified = đã được xác minh độc lập bằng đọc code trực tiếp (không phải chỉ kết quả agent).
+ Không có giá trị secret nào được in trong file này — mọi secret chỉ được tham chiếu theo file:line.
+
+
+
Trạng thái xử lý — cập nhật sau Round 1 + Dependabot (cùng ngày)
CR-07 (next-auth 4.24.15), HI-11 phần resolutions + tar/websocket-driver, next 16.2.11, dependabot.yml — yarn install exit 0, tsc apps/web sạch
+
ĐÃ FIX
HI-08, MD-01
TLS mặc định verify-on (opt-out qua DATABASE_SSL_REJECT_UNAUTHORIZED=false) + sửa pool leak api/v2 + gate session/ADMIN cho /api/webhooks/health — 5/5 test pass
61 migrations cần biết DB prod dùng schema nào; wire workflows dispatcher là quyết định feature. Khi deploy: rotate secret Supabase + env mới (CRON_SECRET, OIDC_*, BREVO/CROVE/DOS_SYNC_WEBHOOK_SECRET, DATABASE_SSL_REJECT_UNAUTHORIZED=false nếu pooler cần) + prisma migrate deploy
Rotate OIDC client secret trên Supabase dashboard NGAY — việc duy nhất thực sự khắc phục C-01; secret nằm vĩnh viễn trong git history của repo public. Xoá code / rewrite history không cứu được.
+ Liên quan: CR-01
+
Bỏ mọi secret fallback (OIDC, video-token) + fail startup khi thiếu env + gate đăng ký DosIdProvider theo cấu hình thật + đổi emailVerified ?? true.
+ CR-01, HI-22, HI-01
+
Tạo migration tái tạo bảng Workflow (hiện /workflows là trang 500 trên DB đã deploy).
+ CR-03
+
Sửa authorization Workflows: membership check bắt buộc, validate activeOn, bọc update trong transaction.
+ CR-02, HI-15
+
HMAC fail-closed + zod cap + rate limit cho webhook Brevo / Crove CRM; báo success đúng thực tế.
+ CR-04, HI-18
+
Bỏ unstable_cache sai key ở /event-types/[type] + bỏ hardcode permissions.
+ CR-05
Khôi phục TLS verification cho Postgres (CA Supabase, opt-in env) + sửa connection leak api/v2.
+ HI-08
+
Bump dependencies: resolutions pins lên bản vá, next-auth 4.24.15, tar 7.5.21, websocket-driver 0.7.5, next 16.2.11; thêm dependabot.yml.
+ CR-07, HI-11
+
Khôi phục CI của fork (type-check/lint/tests/security) + gate deploy-docker sau CI + kích hoạt lại cron + hardening Docker (USER, .dockerignore, bỏ ARG =secret).
+ HI-09, HI-10, HI-12, HI-20, CR-08
Gate teams.get/organizations.get theo membership; guard viewer.teams.create; sửa privilege-escalation ADMIN/OWNER; role check đọc accepted; invite thật thay vì no-op.
+ HI-04, HI-05, HI-06
+
MCP server: principal + tenant scoping mọi where; route booking qua pipeline; sửa fromReschedule; bỏ fallback "first available user"; thêm type-check script.
+ HI-02, HI-03, HI-20
+
Giảm diverge upstream: revert branding fallback trong constants.ts + fix hoặc xoá patch:branding; revert 61 migrations, chuyển sang search_path ở role; sửa JWT update callback (không nhận email từ client).
+ HI-16, HI-13, HI-07
+
Vệ sinh: 13 bare throw → ErrorWithCode; thêm i18n keys + bọc ~200 string hardcode; as any metadata; barrels chết; duplicate hitpay; confirmation dialog khi xoá workflow.
+ HI-17, HI-19, MD-23, LO-01, LO-02, MD-22
+
+
+
Vùng đã kiểm tra và sạch (không cần re-audit)
+
+
credential.key không lộ ra client — quét toàn bộ select/tRPC/serializer; safeCredentialSelect có comment đúng mục đích. Over-fetch duy nhất trong bookingsProcedure middleware không tới response (MD-34).
+
Không SQL injection — 38 site $queryRaw toàn tagged-template parameterized; zero $queryRawUnsafe/$executeRawUnsafe.
+
Không barrel-import violation trong code fork; booking flow upstream không bị fork đụng (packages/features/bookings không đổi).
+
OAuth client management (platform) đúng — secret SHA-256, redirect URI so exact, PKCE S256 bắt buộc cho PUBLIC client, code 40 random bytes.
dos-org-sync verification logic đúng (timingSafeEqual, fail-closed, chạy trước JSON.parse) — lỗi nằm ở key selection (HI-01).
+
Generated files đúng app-store-cli output (8 thêm / 0 xoá, đúng thứ tự alphabet); api/v2 import rule sạch cho code fork.
+
Placement đúng chuẩn — business logic trong Service (không Repository), permission check trong page.tsx (không layout.tsx), không dayjs misuse trong code fork.
+
TS6 decorators an toàn — useDefineForClassFields được set đúng nơi cần; NestJS DTO emission còn nguyên.
+
Test breadth tốt hơn kỳ vọng — mọi feature fork đều có test; WorkflowService.test assert timestamp tính thật (không mock-only).
+
+
+
Danh sách phát hiện
+
+ Tất cả
+ Critical
+ High
+ Medium
+ Low
+ Chỉ đã verify ✓
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example-apps/credential-sync/package.json b/example-apps/credential-sync/package.json
index 406507dabae..a784cfce53d 100644
--- a/example-apps/credential-sync/package.json
+++ b/example-apps/credential-sync/package.json
@@ -10,7 +10,7 @@
"dependencies": {
"@calcom/atoms": "workspace:*",
"@prisma/client": "6.16.1",
- "next": "15.5.15",
+ "next": "15.5.21",
"prisma": "6.16.1",
"react": "18.2.0",
"react-dom": "18.2.0"
@@ -23,6 +23,6 @@
"dotenv": "16.6.1",
"postcss": "8.5.6",
"tailwindcss": "3.4.1",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/package.json b/package.json
index acf20080075..831e697e4cd 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
"example-apps/*"
],
"scripts": {
+ "mcp:server": "yarn workspace @calcom/mcp-server start",
+ "patch:branding": "ts-node --transpile-only scripts/patch-crove-branding.ts",
"app-store-cli": "yarn workspace @calcom/app-store-cli",
"app-store:build": "yarn turbo build --filter=@calcom/app-store-cli",
"app-store:watch": "yarn app-store-cli watch",
@@ -59,7 +61,7 @@
"lint:fix": "turbo lint:fix",
"lint:report": "turbo lint:report",
"lint": "turbo lint",
- "postinstall": "husky install && turbo run post-install",
+ "postinstall": "(husky install || echo \"husky install skipped: no .git directory\") && turbo run post-install",
"pre-commit": "lint-staged",
"predev": "echo 'Checking env files'",
"prisma": "yarn workspace @calcom/prisma prisma",
@@ -113,7 +115,7 @@
"resize-observer-polyfill": "1.5.1",
"tsc-absolute": "1.0.0",
"turbo": "2.7.1",
- "typescript": "5.9.3",
+ "typescript": "6.0.3",
"vitest": "4.1.8",
"vitest-fetch-mock": "0.4.5",
"vitest-mock-extended": "3.1.0"
@@ -134,6 +136,7 @@
"dayjs@1.11.2": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"dayjs@^1": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"dayjs@^1.8.29": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
+ "zod-prisma-types": "patch:zod-prisma-types@npm%3A3.3.11#./.yarn/patches/zod-prisma-types-npm-3.3.11.patch",
"import-in-the-middle": "1.13.1",
"react@19.2.0": "19.2.4",
"react@19.2.1": "19.2.4",
@@ -142,11 +145,11 @@
"rollup": "4.59.0",
"jpeg-js": "0.4.4",
"validator": "13.15.22",
- "form-data": "4.0.4",
- "axios": "1.15.0",
+ "form-data": "4.0.6",
+ "axios": "1.16.0",
"follow-redirects": "1.16.0",
- "protobufjs": "7.5.5",
- "shell-quote": "1.8.4",
+ "protobufjs": "7.6.1",
+ "shell-quote": "1.9.0",
"jws": "4.0.1",
"jsonwebtoken": "9.0.0",
"sha.js": "2.4.12",
@@ -161,37 +164,38 @@
"picomatch@^4.0.2": "4.0.4",
"picomatch@^4.0.3": "4.0.4",
"@modelcontextprotocol/sdk": "1.26.0",
- "hono": "4.12.12",
+ "hono": "4.12.25",
"express-rate-limit": "8.2.2",
- "svgo": "4.0.1",
- "js-yaml": "4.1.1",
+ "svgo": "4.1.0",
+ "js-yaml": "4.3.1",
"mdast-util-to-hast": "13.2.1",
"prismjs": "1.30.0",
"react-devtools-core": "4.28.4",
"formidable": "2.1.3",
"serialize-javascript": "7.0.5",
"@adobe/css-tools": "4.3.2",
- "jsondiffpatch": "0.7.2",
+ "jsondiffpatch": "0.7.6",
"min-document": "2.19.1",
- "tar": "7.5.11",
+ "tar": "7.5.21",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"@lingo.dev/_compiler/fast-xml-parser": "5.5.9",
"fast-xml-parser": "5.5.9",
"bn.js": "4.12.3",
"minimatch@10.0.3": "10.2.4",
- "multer": "2.1.1",
+ "multer": "2.2.0",
"flatted": "3.4.2",
- "socket.io-parser": "4.2.6",
- "vite": "6.4.2",
+ "socket.io-parser": "4.2.7",
+ "vite": "6.4.3",
+ "websocket-driver": "0.7.5",
"defu": "6.1.5",
- "immutable": "3.8.3",
+ "immutable": "3.8.4",
"@hono/node-server": "1.19.13",
- "@xmldom/xmldom@0.9.8": "0.9.9",
- "@xmldom/xmldom@^0.8.1": "0.8.12",
- "@xmldom/xmldom@^0.8.5": "0.8.12",
- "@xmldom/xmldom@^0.8.8": "0.8.12",
- "@xmldom/xmldom@^0.8.10": "0.8.12",
+ "@xmldom/xmldom@0.9.8": "0.9.10",
+ "@xmldom/xmldom@^0.8.1": "0.8.13",
+ "@xmldom/xmldom@^0.8.5": "0.8.13",
+ "@xmldom/xmldom@^0.8.8": "0.8.13",
+ "@xmldom/xmldom@^0.8.10": "0.8.13",
"yaml@2.8.1": "2.8.3",
"yaml@^2.0.0": "2.8.3",
"yaml@^2.1.1": "2.8.3",
@@ -206,9 +210,22 @@
"ajv@^8.6.3": "8.18.0",
"ajv@^8.17.1": "8.18.0",
"ajv@^6.12.5": "6.14.0",
- "brace-expansion@^5.0.2": "5.0.5",
- "brace-expansion@^2.0.1": "2.0.3",
- "brace-expansion@^2.0.2": "2.0.3",
+ "brace-expansion@^5.0.2": "5.0.9",
+ "brace-expansion@^2.0.1": "2.1.4",
+ "brace-expansion@^2.0.2": "2.1.4",
+ "postcss": "8.5.18",
+ "fast-uri": "3.1.6",
+ "browserslist": "4.28.7",
+ "engine.io": "6.6.7",
+ "mysql2": "3.22.0",
+ "@grpc/grpc-js": "1.12.7",
+ "ip-address": "10.3.1",
+ "js-cookie": "3.0.7",
+ "kysely": "0.28.17",
+ "nanoid@^3.0.0": "3.3.18",
+ "nanoid@^5.0.0": "5.1.16",
+ "ws@^7.0.0": "7.5.11",
+ "ws@^8.0.0": "8.21.0",
"i18next-fs-backend": "^2.6.6"
},
"packageExtensions": {
@@ -226,6 +243,11 @@
"schema": "packages/prisma/schema.prisma",
"seed": "ts-node --transpile-only ./packages/prisma/seed.ts"
},
+ "dependenciesMeta": {
+ "deasync": {
+ "built": false
+ }
+ },
"packageManager": "yarn@4.12.0",
"syncpack": {
"filter": "^(?!@calcom).*",
diff --git a/packages/app-store-cli/package.json b/packages/app-store-cli/package.json
index b3337c61cde..1c12c0e2d1a 100644
--- a/packages/app-store-cli/package.json
+++ b/packages/app-store-cli/package.json
@@ -30,6 +30,6 @@
"@types/react": "18.0.26",
"chokidar": "3.6.0",
"ts-node": "10.9.2",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/app-store-cli/tsconfig.json b/packages/app-store-cli/tsconfig.json
index 6fffe5dbd18..67fdb6d67b9 100644
--- a/packages/app-store-cli/tsconfig.json
+++ b/packages/app-store-cli/tsconfig.json
@@ -1,10 +1,13 @@
{
+ "extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
+ "ignoreDeprecations": "6.0",
"strict": true,
"module": "commonjs",
"jsx": "react-jsx",
"esModuleInterop": true,
"outDir": "dist",
+ "rootDir": ".",
"noEmitOnError": false,
"target": "ES2020",
"baseUrl": ".",
diff --git a/packages/app-store/analytics.services.generated.ts b/packages/app-store/analytics.services.generated.ts
deleted file mode 100644
index 92bc7220830..00000000000
--- a/packages/app-store/analytics.services.generated.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const AnalyticsServiceMap =
- process.env.NEXT_PUBLIC_IS_E2E === "1"
- ? {}
- : {
- dub: import("./dub/lib/AnalyticsService"),
- };
diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx
deleted file mode 100644
index cf6fef19fff..00000000000
--- a/packages/app-store/apps.browser.generated.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-import dynamic from "next/dynamic";
-export const InstallAppButtonMap = {
- exchange2013calendar: dynamic(() => import("./exchange2013calendar/components/InstallAppButton")),
- exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
- office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
- vital: dynamic(() => import("./vital/components/InstallAppButton")),
-};
-export const AppSettingsComponentsMap = {
- "general-app-settings": dynamic(
- () => import("./templates/general-app-settings/components/AppSettingsInterface")
- ),
- weather_in_your_calendar: dynamic(
- () => import("./weather_in_your_calendar/components/AppSettingsInterface")
- ),
- zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
-};
-export const EventTypeAddonMap = {
- alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
- basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
- btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
- closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
- databuddy: dynamic(() => import("./databuddy/components/EventTypeAppCardInterface")),
- fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
- ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
- giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
- gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
- hitpay: dynamic(() => import("./hitpay/components/EventTypeAppCardInterface")),
- hubspot: dynamic(() => import("./hubspot/components/EventTypeAppCardInterface")),
- insihts: dynamic(() => import("./insihts/components/EventTypeAppCardInterface")),
- matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")),
- metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
- "mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")),
- paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
- "pipedrive-crm": dynamic(() => import("./pipedrive-crm/components/EventTypeAppCardInterface")),
- plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
- posthog: dynamic(() => import("./posthog/components/EventTypeAppCardInterface")),
- qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
- salesforce: dynamic(() => import("./salesforce/components/EventTypeAppCardInterface")),
- stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
- "booking-pages-tag": dynamic(
- () => import("./templates/booking-pages-tag/components/EventTypeAppCardInterface")
- ),
- "event-type-app-card": dynamic(
- () => import("./templates/event-type-app-card/components/EventTypeAppCardInterface")
- ),
- twipla: dynamic(() => import("./twipla/components/EventTypeAppCardInterface")),
- umami: dynamic(() => import("./umami/components/EventTypeAppCardInterface")),
- "zoho-bigin": dynamic(() => import("./zoho-bigin/components/EventTypeAppCardInterface")),
- zohocrm: dynamic(() => import("./zohocrm/components/EventTypeAppCardInterface")),
-};
-export const EventTypeSettingsMap = {
- alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
- basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
- btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
- databuddy: dynamic(() => import("./databuddy/components/EventTypeAppSettingsInterface")),
- fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
- ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
- giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
- gtm: dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")),
- hitpay: dynamic(() => import("./hitpay/components/EventTypeAppSettingsInterface")),
- metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")),
- paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")),
- plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")),
- qr_code: dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")),
- stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")),
-};
diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts
deleted file mode 100644
index 43e1a41e4b7..00000000000
--- a/packages/app-store/apps.keys-schemas.generated.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-import { appKeysSchema as alby_zod_ts } from "./alby/zod";
-import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
-import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
-import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
-import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
-import { appKeysSchema as databuddy_zod_ts } from "./databuddy/zod";
-import { appKeysSchema as dub_zod_ts } from "./dub/zod";
-import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
-import { appKeysSchema as feishucalendar_zod_ts } from "./feishucalendar/zod";
-import { appKeysSchema as ga4_zod_ts } from "./ga4/zod";
-import { appKeysSchema as giphy_zod_ts } from "./giphy/zod";
-import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
-import { appKeysSchema as googlevideo_zod_ts } from "./googlevideo/zod";
-import { appKeysSchema as gtm_zod_ts } from "./gtm/zod";
-import { appKeysSchema as hitpay_zod_ts } from "./hitpay/zod";
-import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod";
-import { appKeysSchema as insihts_zod_ts } from "./insihts/zod";
-import { appKeysSchema as intercom_zod_ts } from "./intercom/zod";
-import { appKeysSchema as jelly_zod_ts } from "./jelly/zod";
-import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
-import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
-import { appKeysSchema as lyra_zod_ts } from "./lyra/zod";
-import { appKeysSchema as make_zod_ts } from "./make/zod";
-import { appKeysSchema as matomo_zod_ts } from "./matomo/zod";
-import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
-import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
-import { appKeysSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod";
-import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
-import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
-import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
-import { appKeysSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod";
-import { appKeysSchema as plausible_zod_ts } from "./plausible/zod";
-import { appKeysSchema as posthog_zod_ts } from "./posthog/zod";
-import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod";
-import { appKeysSchema as salesforce_zod_ts } from "./salesforce/zod";
-import { appKeysSchema as shimmervideo_zod_ts } from "./shimmervideo/zod";
-import { appKeysSchema as stripepayment_zod_ts } from "./stripepayment/zod";
-import { appKeysSchema as tandemvideo_zod_ts } from "./tandemvideo/zod";
-import { appKeysSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod";
-import { appKeysSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod";
-import { appKeysSchema as twipla_zod_ts } from "./twipla/zod";
-import { appKeysSchema as umami_zod_ts } from "./umami/zod";
-import { appKeysSchema as vital_zod_ts } from "./vital/zod";
-import { appKeysSchema as webex_zod_ts } from "./webex/zod";
-import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
-import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
-import { appKeysSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
-import { appKeysSchema as zohocalendar_zod_ts } from "./zohocalendar/zod";
-import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
-import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
-export const appKeysSchemas = {
- alby: alby_zod_ts,
- basecamp3: basecamp3_zod_ts,
- btcpayserver: btcpayserver_zod_ts,
- closecom: closecom_zod_ts,
- dailyvideo: dailyvideo_zod_ts,
- databuddy: databuddy_zod_ts,
- dub: dub_zod_ts,
- fathom: fathom_zod_ts,
- feishucalendar: feishucalendar_zod_ts,
- ga4: ga4_zod_ts,
- giphy: giphy_zod_ts,
- googlecalendar: googlecalendar_zod_ts,
- googlevideo: googlevideo_zod_ts,
- gtm: gtm_zod_ts,
- hitpay: hitpay_zod_ts,
- hubspot: hubspot_zod_ts,
- insihts: insihts_zod_ts,
- intercom: intercom_zod_ts,
- jelly: jelly_zod_ts,
- jitsivideo: jitsivideo_zod_ts,
- larkcalendar: larkcalendar_zod_ts,
- lyra: lyra_zod_ts,
- make: make_zod_ts,
- matomo: matomo_zod_ts,
- metapixel: metapixel_zod_ts,
- "mock-payment-app": mock_payment_app_zod_ts,
- nextcloudtalk: nextcloudtalk_zod_ts,
- office365calendar: office365calendar_zod_ts,
- office365video: office365video_zod_ts,
- paypal: paypal_zod_ts,
- "pipedrive-crm": pipedrive_crm_zod_ts,
- plausible: plausible_zod_ts,
- posthog: posthog_zod_ts,
- qr_code: qr_code_zod_ts,
- salesforce: salesforce_zod_ts,
- shimmervideo: shimmervideo_zod_ts,
- stripe: stripepayment_zod_ts,
- tandemvideo: tandemvideo_zod_ts,
- "booking-pages-tag": booking_pages_tag_zod_ts,
- "event-type-app-card": event_type_app_card_zod_ts,
- twipla: twipla_zod_ts,
- umami: umami_zod_ts,
- vital: vital_zod_ts,
- webex: webex_zod_ts,
- wordpress: wordpress_zod_ts,
- zapier: zapier_zod_ts,
- "zoho-bigin": zoho_bigin_zod_ts,
- zohocalendar: zohocalendar_zod_ts,
- zohocrm: zohocrm_zod_ts,
- zoomvideo: zoomvideo_zod_ts,
-};
diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts
deleted file mode 100644
index bb666932ded..00000000000
--- a/packages/app-store/apps.metadata.generated.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-import alby_config_json from "./alby/config.json";
-import amie_config_json from "./amie/config.json";
-import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
-import attio_config_json from "./attio/config.json";
-import autocheckin_config_json from "./autocheckin/config.json";
-import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
-import basecamp3_config_json from "./basecamp3/config.json";
-import bolna_config_json from "./bolna/config.json";
-import btcpayserver_config_json from "./btcpayserver/config.json";
-import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
-import campfire_config_json from "./campfire/config.json";
-import caretta_config_json from "./caretta/config.json";
-import chatbase_config_json from "./chatbase/config.json";
-import clara_config_json from "./clara/config.json";
-import clic_config_json from "./clic/config.json";
-import closecom_config_json from "./closecom/config.json";
-import cron_config_json from "./cron/config.json";
-import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
-import databuddy_config_json from "./databuddy/config.json";
-import deel_config_json from "./deel/config.json";
-import demodesk_config_json from "./demodesk/config.json";
-import dialpad_config_json from "./dialpad/config.json";
-import discord_config_json from "./discord/config.json";
-import dub_config_json from "./dub/config.json";
-import eightxeight_config_json from "./eightxeight/config.json";
-import element_call_config_json from "./element-call/config.json";
-import elevenlabs_config_json from "./elevenlabs/config.json";
-import { metadata as exchange2013calendar__metadata_ts } from "./exchange2013calendar/_metadata";
-import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016calendar/_metadata";
-import exchangecalendar_config_json from "./exchangecalendar/config.json";
-import facetime_config_json from "./facetime/config.json";
-import famulor_config_json from "./famulor/config.json";
-import fathom_config_json from "./fathom/config.json";
-import { metadata as feishucalendar__metadata_ts } from "./feishucalendar/_metadata";
-import fonio_ai_config_json from "./fonio-ai/config.json";
-import framer_config_json from "./framer/config.json";
-import ga4_config_json from "./ga4/config.json";
-import { metadata as giphy__metadata_ts } from "./giphy/_metadata";
-import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metadata";
-import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
-import granola_config_json from "./granola/config.json";
-import greetmate_ai_config_json from "./greetmate-ai/config.json";
-import gtm_config_json from "./gtm/config.json";
-import hitpay_config_json from "./hitpay/config.json";
-import horizon_workrooms_config_json from "./horizon-workrooms/config.json";
-import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
-import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
-import ics_feedcalendar_config_json from "./ics-feedcalendar/config.json";
-import insihts_config_json from "./insihts/config.json";
-import intercom_config_json from "./intercom/config.json";
-import jelly_config_json from "./jelly/config.json";
-import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
-import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
-import lindy_config_json from "./lindy/config.json";
-import linear_config_json from "./linear/config.json";
-import lyra_config_json from "./lyra/config.json";
-import make_config_json from "./make/config.json";
-import matomo_config_json from "./matomo/config.json";
-import metapixel_config_json from "./metapixel/config.json";
-import millis_ai_config_json from "./millis-ai/config.json";
-import mirotalk_config_json from "./mirotalk/config.json";
-import mock_payment_app_config_json from "./mock-payment-app/config.json";
-import monobot_config_json from "./monobot/config.json";
-import n8n_config_json from "./n8n/config.json";
-import nextcloudtalk_config_json from "./nextcloudtalk/config.json";
-import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
-import office365video_config_json from "./office365video/config.json";
-import paypal_config_json from "./paypal/config.json";
-import ping_config_json from "./ping/config.json";
-import pipedream_config_json from "./pipedream/config.json";
-import pipedrive_crm_config_json from "./pipedrive-crm/config.json";
-import plausible_config_json from "./plausible/config.json";
-import posthog_config_json from "./posthog/config.json";
-import qr_code_config_json from "./qr_code/config.json";
-import raycast_config_json from "./raycast/config.json";
-import retell_ai_config_json from "./retell-ai/config.json";
-import riverside_config_json from "./riverside/config.json";
-import roam_config_json from "./roam/config.json";
-import salesforce_config_json from "./salesforce/config.json";
-import salesroom_config_json from "./salesroom/config.json";
-import sendgrid_config_json from "./sendgrid/config.json";
-import shimmervideo_config_json from "./shimmervideo/config.json";
-import signal_config_json from "./signal/config.json";
-import sirius_video_config_json from "./sirius_video/config.json";
-import skype_config_json from "./skype/config.json";
-import { metadata as stripepayment__metadata_ts } from "./stripepayment/_metadata";
-import sylapsvideo_config_json from "./sylapsvideo/config.json";
-import synthflow_config_json from "./synthflow/config.json";
-import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata";
-import telegram_config_json from "./telegram/config.json";
-import telli_config_json from "./telli/config.json";
-import basic_config_json from "./templates/basic/config.json";
-import booking_pages_tag_config_json from "./templates/booking-pages-tag/config.json";
-import event_type_app_card_config_json from "./templates/event-type-app-card/config.json";
-import event_type_location_video_static_config_json from "./templates/event-type-location-video-static/config.json";
-import general_app_settings_config_json from "./templates/general-app-settings/config.json";
-import link_as_an_app_config_json from "./templates/link-as-an-app/config.json";
-import twipla_config_json from "./twipla/config.json";
-import umami_config_json from "./umami/config.json";
-import vimcal_config_json from "./vimcal/config.json";
-import { metadata as vital__metadata_ts } from "./vital/_metadata";
-import weather_in_your_calendar_config_json from "./weather_in_your_calendar/config.json";
-import webex_config_json from "./webex/config.json";
-import whatsapp_config_json from "./whatsapp/config.json";
-import whereby_config_json from "./whereby/config.json";
-import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata";
-import wordpress_config_json from "./wordpress/config.json";
-import zapier_config_json from "./zapier/config.json";
-import zoho_bigin_config_json from "./zoho-bigin/config.json";
-import zohocalendar_config_json from "./zohocalendar/config.json";
-import zohocrm_config_json from "./zohocrm/config.json";
-import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
-export const appStoreMetadata = {
- alby: alby_config_json,
- amie: amie_config_json,
- applecalendar: applecalendar__metadata_ts,
- attio: attio_config_json,
- autocheckin: autocheckin_config_json,
- "baa-for-hipaa": baa_for_hipaa_config_json,
- basecamp3: basecamp3_config_json,
- bolna: bolna_config_json,
- btcpayserver: btcpayserver_config_json,
- caldavcalendar: caldavcalendar__metadata_ts,
- campfire: campfire_config_json,
- caretta: caretta_config_json,
- chatbase: chatbase_config_json,
- clara: clara_config_json,
- clic: clic_config_json,
- closecom: closecom_config_json,
- cron: cron_config_json,
- dailyvideo: dailyvideo__metadata_ts,
- databuddy: databuddy_config_json,
- deel: deel_config_json,
- demodesk: demodesk_config_json,
- dialpad: dialpad_config_json,
- discord: discord_config_json,
- dub: dub_config_json,
- eightxeight: eightxeight_config_json,
- "element-call": element_call_config_json,
- elevenlabs: elevenlabs_config_json,
- exchange2013calendar: exchange2013calendar__metadata_ts,
- exchange2016calendar: exchange2016calendar__metadata_ts,
- exchangecalendar: exchangecalendar_config_json,
- facetime: facetime_config_json,
- famulor: famulor_config_json,
- fathom: fathom_config_json,
- feishucalendar: feishucalendar__metadata_ts,
- "fonio-ai": fonio_ai_config_json,
- framer: framer_config_json,
- ga4: ga4_config_json,
- giphy: giphy__metadata_ts,
- googlecalendar: googlecalendar__metadata_ts,
- googlevideo: googlevideo__metadata_ts,
- granola: granola_config_json,
- "greetmate-ai": greetmate_ai_config_json,
- gtm: gtm_config_json,
- hitpay: hitpay_config_json,
- "horizon-workrooms": horizon_workrooms_config_json,
- hubspot: hubspot__metadata_ts,
- huddle01video: huddle01video__metadata_ts,
- "ics-feedcalendar": ics_feedcalendar_config_json,
- insihts: insihts_config_json,
- intercom: intercom_config_json,
- jelly: jelly_config_json,
- jitsivideo: jitsivideo__metadata_ts,
- larkcalendar: larkcalendar__metadata_ts,
- lindy: lindy_config_json,
- linear: linear_config_json,
- lyra: lyra_config_json,
- make: make_config_json,
- matomo: matomo_config_json,
- metapixel: metapixel_config_json,
- "millis-ai": millis_ai_config_json,
- mirotalk: mirotalk_config_json,
- "mock-payment-app": mock_payment_app_config_json,
- monobot: monobot_config_json,
- n8n: n8n_config_json,
- nextcloudtalk: nextcloudtalk_config_json,
- office365calendar: office365calendar__metadata_ts,
- office365video: office365video_config_json,
- paypal: paypal_config_json,
- ping: ping_config_json,
- pipedream: pipedream_config_json,
- "pipedrive-crm": pipedrive_crm_config_json,
- plausible: plausible_config_json,
- posthog: posthog_config_json,
- qr_code: qr_code_config_json,
- raycast: raycast_config_json,
- "retell-ai": retell_ai_config_json,
- riverside: riverside_config_json,
- roam: roam_config_json,
- salesforce: salesforce_config_json,
- salesroom: salesroom_config_json,
- sendgrid: sendgrid_config_json,
- shimmervideo: shimmervideo_config_json,
- signal: signal_config_json,
- sirius_video: sirius_video_config_json,
- skype: skype_config_json,
- stripepayment: stripepayment__metadata_ts,
- sylapsvideo: sylapsvideo_config_json,
- synthflow: synthflow_config_json,
- tandemvideo: tandemvideo__metadata_ts,
- telegram: telegram_config_json,
- telli: telli_config_json,
- basic: basic_config_json,
- "booking-pages-tag": booking_pages_tag_config_json,
- "event-type-app-card": event_type_app_card_config_json,
- "event-type-location-video-static": event_type_location_video_static_config_json,
- "general-app-settings": general_app_settings_config_json,
- "link-as-an-app": link_as_an_app_config_json,
- twipla: twipla_config_json,
- umami: umami_config_json,
- vimcal: vimcal_config_json,
- vital: vital__metadata_ts,
- weather_in_your_calendar: weather_in_your_calendar_config_json,
- webex: webex_config_json,
- whatsapp: whatsapp_config_json,
- whereby: whereby_config_json,
- wipemycalother: wipemycalother__metadata_ts,
- wordpress: wordpress_config_json,
- zapier: zapier_config_json,
- "zoho-bigin": zoho_bigin_config_json,
- zohocalendar: zohocalendar_config_json,
- zohocrm: zohocrm_config_json,
- zoomvideo: zoomvideo__metadata_ts,
-};
diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts
deleted file mode 100644
index 4085408dbeb..00000000000
--- a/packages/app-store/apps.schemas.generated.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-import { appDataSchema as alby_zod_ts } from "./alby/zod";
-import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
-import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
-import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
-import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
-import { appDataSchema as databuddy_zod_ts } from "./databuddy/zod";
-import { appDataSchema as dub_zod_ts } from "./dub/zod";
-import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
-import { appDataSchema as feishucalendar_zod_ts } from "./feishucalendar/zod";
-import { appDataSchema as ga4_zod_ts } from "./ga4/zod";
-import { appDataSchema as giphy_zod_ts } from "./giphy/zod";
-import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
-import { appDataSchema as googlevideo_zod_ts } from "./googlevideo/zod";
-import { appDataSchema as gtm_zod_ts } from "./gtm/zod";
-import { appDataSchema as hitpay_zod_ts } from "./hitpay/zod";
-import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod";
-import { appDataSchema as insihts_zod_ts } from "./insihts/zod";
-import { appDataSchema as intercom_zod_ts } from "./intercom/zod";
-import { appDataSchema as jelly_zod_ts } from "./jelly/zod";
-import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
-import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
-import { appDataSchema as lyra_zod_ts } from "./lyra/zod";
-import { appDataSchema as make_zod_ts } from "./make/zod";
-import { appDataSchema as matomo_zod_ts } from "./matomo/zod";
-import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
-import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
-import { appDataSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod";
-import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
-import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
-import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
-import { appDataSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod";
-import { appDataSchema as plausible_zod_ts } from "./plausible/zod";
-import { appDataSchema as posthog_zod_ts } from "./posthog/zod";
-import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod";
-import { appDataSchema as salesforce_zod_ts } from "./salesforce/zod";
-import { appDataSchema as shimmervideo_zod_ts } from "./shimmervideo/zod";
-import { appDataSchema as stripepayment_zod_ts } from "./stripepayment/zod";
-import { appDataSchema as tandemvideo_zod_ts } from "./tandemvideo/zod";
-import { appDataSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod";
-import { appDataSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod";
-import { appDataSchema as twipla_zod_ts } from "./twipla/zod";
-import { appDataSchema as umami_zod_ts } from "./umami/zod";
-import { appDataSchema as vital_zod_ts } from "./vital/zod";
-import { appDataSchema as webex_zod_ts } from "./webex/zod";
-import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
-import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
-import { appDataSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
-import { appDataSchema as zohocalendar_zod_ts } from "./zohocalendar/zod";
-import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
-import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
-export const appDataSchemas = {
- alby: alby_zod_ts,
- basecamp3: basecamp3_zod_ts,
- btcpayserver: btcpayserver_zod_ts,
- closecom: closecom_zod_ts,
- dailyvideo: dailyvideo_zod_ts,
- databuddy: databuddy_zod_ts,
- dub: dub_zod_ts,
- fathom: fathom_zod_ts,
- feishucalendar: feishucalendar_zod_ts,
- ga4: ga4_zod_ts,
- giphy: giphy_zod_ts,
- googlecalendar: googlecalendar_zod_ts,
- googlevideo: googlevideo_zod_ts,
- gtm: gtm_zod_ts,
- hitpay: hitpay_zod_ts,
- hubspot: hubspot_zod_ts,
- insihts: insihts_zod_ts,
- intercom: intercom_zod_ts,
- jelly: jelly_zod_ts,
- jitsivideo: jitsivideo_zod_ts,
- larkcalendar: larkcalendar_zod_ts,
- lyra: lyra_zod_ts,
- make: make_zod_ts,
- matomo: matomo_zod_ts,
- metapixel: metapixel_zod_ts,
- "mock-payment-app": mock_payment_app_zod_ts,
- nextcloudtalk: nextcloudtalk_zod_ts,
- office365calendar: office365calendar_zod_ts,
- office365video: office365video_zod_ts,
- paypal: paypal_zod_ts,
- "pipedrive-crm": pipedrive_crm_zod_ts,
- plausible: plausible_zod_ts,
- posthog: posthog_zod_ts,
- qr_code: qr_code_zod_ts,
- salesforce: salesforce_zod_ts,
- shimmervideo: shimmervideo_zod_ts,
- stripe: stripepayment_zod_ts,
- tandemvideo: tandemvideo_zod_ts,
- "booking-pages-tag": booking_pages_tag_zod_ts,
- "event-type-app-card": event_type_app_card_zod_ts,
- twipla: twipla_zod_ts,
- umami: umami_zod_ts,
- vital: vital_zod_ts,
- webex: webex_zod_ts,
- wordpress: wordpress_zod_ts,
- zapier: zapier_zod_ts,
- "zoho-bigin": zoho_bigin_zod_ts,
- zohocalendar: zohocalendar_zod_ts,
- zohocrm: zohocrm_zod_ts,
- zoomvideo: zoomvideo_zod_ts,
-};
diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts
deleted file mode 100644
index d5c6fa9f950..00000000000
--- a/packages/app-store/apps.server.generated.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const apiHandlers = {
- alby: import("./alby/api"),
- applecalendar: import("./applecalendar/api"),
- attio: import("./attio/api"),
- basecamp3: import("./basecamp3/api"),
- btcpayserver: import("./btcpayserver/api"),
- caldavcalendar: import("./caldavcalendar/api"),
- campfire: import("./campfire/api"),
- closecom: import("./closecom/api"),
- databuddy: import("./databuddy/api"),
- demodesk: import("./demodesk/api"),
- dialpad: import("./dialpad/api"),
- discord: import("./discord/api"),
- dub: import("./dub/api"),
- eightxeight: import("./eightxeight/api"),
- "element-call": import("./element-call/api"),
- exchange2013calendar: import("./exchange2013calendar/api"),
- exchange2016calendar: import("./exchange2016calendar/api"),
- exchangecalendar: import("./exchangecalendar/api"),
- facetime: import("./facetime/api"),
- famulor: import("./famulor/api"),
- fathom: import("./fathom/api"),
- feishucalendar: import("./feishucalendar/api"),
- ga4: import("./ga4/api"),
- giphy: import("./giphy/api"),
- googlecalendar: import("./googlecalendar/api"),
- googlevideo: import("./googlevideo/api"),
- gtm: import("./gtm/api"),
- hitpay: import("./hitpay/api"),
- "horizon-workrooms": import("./horizon-workrooms/api"),
- hubspot: import("./hubspot/api"),
- huddle01video: import("./huddle01video/api"),
- "ics-feedcalendar": import("./ics-feedcalendar/api"),
- insihts: import("./insihts/api"),
- intercom: import("./intercom/api"),
- jelly: import("./jelly/api"),
- jitsivideo: import("./jitsivideo/api"),
- larkcalendar: import("./larkcalendar/api"),
- linear: import("./linear/api"),
- lyra: import("./lyra/api"),
- make: import("./make/api"),
- matomo: import("./matomo/api"),
- metapixel: import("./metapixel/api"),
- mirotalk: import("./mirotalk/api"),
- "mock-payment-app": import("./mock-payment-app/api"),
- nextcloudtalk: import("./nextcloudtalk/api"),
- office365calendar: import("./office365calendar/api"),
- office365video: import("./office365video/api"),
- paypal: import("./paypal/api"),
- ping: import("./ping/api"),
- "pipedrive-crm": import("./pipedrive-crm/api"),
- plausible: import("./plausible/api"),
- posthog: import("./posthog/api"),
- qr_code: import("./qr_code/api"),
- riverside: import("./riverside/api"),
- roam: import("./roam/api"),
- salesforce: import("./salesforce/api"),
- salesroom: import("./salesroom/api"),
- sendgrid: import("./sendgrid/api"),
- shimmervideo: import("./shimmervideo/api"),
- signal: import("./signal/api"),
- sirius_video: import("./sirius_video/api"),
- skype: import("./skype/api"),
- stripepayment: import("./stripepayment/api"),
- sylapsvideo: import("./sylapsvideo/api"),
- tandemvideo: import("./tandemvideo/api"),
- telegram: import("./telegram/api"),
- basic: import("./templates/basic/api"),
- "booking-pages-tag": import("./templates/booking-pages-tag/api"),
- "event-type-app-card": import("./templates/event-type-app-card/api"),
- "event-type-location-video-static": import("./templates/event-type-location-video-static/api"),
- "general-app-settings": import("./templates/general-app-settings/api"),
- twipla: import("./twipla/api"),
- umami: import("./umami/api"),
- vital: import("./vital/api"),
- weather_in_your_calendar: import("./weather_in_your_calendar/api"),
- webex: import("./webex/api"),
- whatsapp: import("./whatsapp/api"),
- whereby: import("./whereby/api"),
- wipemycalother: import("./wipemycalother/api"),
- zapier: import("./zapier/api"),
- "zoho-bigin": import("./zoho-bigin/api"),
- zohocalendar: import("./zohocalendar/api"),
- zohocrm: import("./zohocrm/api"),
- zoomvideo: import("./zoomvideo/api"),
-};
diff --git a/packages/app-store/bookerApps.metadata.generated.ts b/packages/app-store/bookerApps.metadata.generated.ts
deleted file mode 100644
index 16d9e3f77e5..00000000000
--- a/packages/app-store/bookerApps.metadata.generated.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-import campfire_config_json from "./campfire/config.json";
-import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
-import databuddy_config_json from "./databuddy/config.json";
-import demodesk_config_json from "./demodesk/config.json";
-import dialpad_config_json from "./dialpad/config.json";
-import discord_config_json from "./discord/config.json";
-import eightxeight_config_json from "./eightxeight/config.json";
-import element_call_config_json from "./element-call/config.json";
-import facetime_config_json from "./facetime/config.json";
-import fathom_config_json from "./fathom/config.json";
-import ga4_config_json from "./ga4/config.json";
-import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
-import gtm_config_json from "./gtm/config.json";
-import horizon_workrooms_config_json from "./horizon-workrooms/config.json";
-import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
-import insihts_config_json from "./insihts/config.json";
-import jelly_config_json from "./jelly/config.json";
-import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
-import lyra_config_json from "./lyra/config.json";
-import matomo_config_json from "./matomo/config.json";
-import metapixel_config_json from "./metapixel/config.json";
-import mirotalk_config_json from "./mirotalk/config.json";
-import nextcloudtalk_config_json from "./nextcloudtalk/config.json";
-import office365video_config_json from "./office365video/config.json";
-import ping_config_json from "./ping/config.json";
-import plausible_config_json from "./plausible/config.json";
-import posthog_config_json from "./posthog/config.json";
-import riverside_config_json from "./riverside/config.json";
-import roam_config_json from "./roam/config.json";
-import salesroom_config_json from "./salesroom/config.json";
-import shimmervideo_config_json from "./shimmervideo/config.json";
-import signal_config_json from "./signal/config.json";
-import sirius_video_config_json from "./sirius_video/config.json";
-import skype_config_json from "./skype/config.json";
-import sylapsvideo_config_json from "./sylapsvideo/config.json";
-import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata";
-import telegram_config_json from "./telegram/config.json";
-import booking_pages_tag_config_json from "./templates/booking-pages-tag/config.json";
-import event_type_location_video_static_config_json from "./templates/event-type-location-video-static/config.json";
-import twipla_config_json from "./twipla/config.json";
-import umami_config_json from "./umami/config.json";
-import webex_config_json from "./webex/config.json";
-import whatsapp_config_json from "./whatsapp/config.json";
-import whereby_config_json from "./whereby/config.json";
-import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
-export const appStoreMetadata = {
- campfire: campfire_config_json,
- dailyvideo: dailyvideo__metadata_ts,
- databuddy: databuddy_config_json,
- demodesk: demodesk_config_json,
- dialpad: dialpad_config_json,
- discord: discord_config_json,
- eightxeight: eightxeight_config_json,
- "element-call": element_call_config_json,
- facetime: facetime_config_json,
- fathom: fathom_config_json,
- ga4: ga4_config_json,
- googlevideo: googlevideo__metadata_ts,
- gtm: gtm_config_json,
- "horizon-workrooms": horizon_workrooms_config_json,
- huddle01video: huddle01video__metadata_ts,
- insihts: insihts_config_json,
- jelly: jelly_config_json,
- jitsivideo: jitsivideo__metadata_ts,
- lyra: lyra_config_json,
- matomo: matomo_config_json,
- metapixel: metapixel_config_json,
- mirotalk: mirotalk_config_json,
- nextcloudtalk: nextcloudtalk_config_json,
- office365video: office365video_config_json,
- ping: ping_config_json,
- plausible: plausible_config_json,
- posthog: posthog_config_json,
- riverside: riverside_config_json,
- roam: roam_config_json,
- salesroom: salesroom_config_json,
- shimmervideo: shimmervideo_config_json,
- signal: signal_config_json,
- sirius_video: sirius_video_config_json,
- skype: skype_config_json,
- sylapsvideo: sylapsvideo_config_json,
- tandemvideo: tandemvideo__metadata_ts,
- telegram: telegram_config_json,
- "booking-pages-tag": booking_pages_tag_config_json,
- "event-type-location-video-static": event_type_location_video_static_config_json,
- twipla: twipla_config_json,
- umami: umami_config_json,
- webex: webex_config_json,
- whatsapp: whatsapp_config_json,
- whereby: whereby_config_json,
- zoomvideo: zoomvideo__metadata_ts,
-};
diff --git a/packages/app-store/calendar.services.generated.ts b/packages/app-store/calendar.services.generated.ts
deleted file mode 100644
index 9795cfe01ea..00000000000
--- a/packages/app-store/calendar.services.generated.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const CalendarServiceMap =
- process.env.NEXT_PUBLIC_IS_E2E === "1"
- ? {}
- : {
- applecalendar: import("./applecalendar/lib/CalendarService"),
- caldavcalendar: import("./caldavcalendar/lib/CalendarService"),
- exchange2013calendar: import("./exchange2013calendar/lib/CalendarService"),
- exchange2016calendar: import("./exchange2016calendar/lib/CalendarService"),
- exchangecalendar: import("./exchangecalendar/lib/CalendarService"),
- feishucalendar: import("./feishucalendar/lib/CalendarService"),
- googlecalendar: import("./googlecalendar/lib/CalendarService"),
- "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"),
- larkcalendar: import("./larkcalendar/lib/CalendarService"),
- office365calendar: import("./office365calendar/lib/CalendarService"),
- zohocalendar: import("./zohocalendar/lib/CalendarService"),
- };
diff --git a/packages/app-store/crm.apps.generated.ts b/packages/app-store/crm.apps.generated.ts
deleted file mode 100644
index 94392d5ee50..00000000000
--- a/packages/app-store/crm.apps.generated.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const CrmServiceMap = {
- closecom: import("./closecom/lib/CrmService"),
- hubspot: import("./hubspot/lib/CrmService"),
- "pipedrive-crm": import("./pipedrive-crm/lib/CrmService"),
- salesforce: import("./salesforce/lib/CrmService"),
- "zoho-bigin": import("./zoho-bigin/lib/CrmService"),
- zohocrm: import("./zohocrm/lib/CrmService"),
-};
diff --git a/packages/app-store/crovecrm/components/EventTypeAppCardInterface.tsx b/packages/app-store/crovecrm/components/EventTypeAppCardInterface.tsx
new file mode 100644
index 00000000000..4fd338c3516
--- /dev/null
+++ b/packages/app-store/crovecrm/components/EventTypeAppCardInterface.tsx
@@ -0,0 +1,26 @@
+import { usePathname } from "next/navigation";
+import AppCard from "@calcom/app-store/_components/AppCard";
+import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
+import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+
+const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType, onAppInstallSuccess }) {
+ const pathname = usePathname();
+ const { enabled, updateEnabled } = useIsAppEnabled(app);
+
+ return (
+ {
+ updateEnabled(e);
+ }}
+ switchChecked={enabled}
+ hideAppCardOptions
+ />
+ );
+};
+
+export default EventTypeAppCard;
diff --git a/packages/app-store/crovecrm/config.json b/packages/app-store/crovecrm/config.json
new file mode 100644
index 00000000000..0fa1f1b7ab1
--- /dev/null
+++ b/packages/app-store/crovecrm/config.json
@@ -0,0 +1,16 @@
+{
+ "name": "Crove CRM",
+ "slug": "crovecrm",
+ "type": "crovecrm_crm",
+ "logo": "icon.svg",
+ "url": "https://crm.crove.com",
+ "variant": "crm",
+ "categories": ["crm"],
+ "extendsFeature": "EventType",
+ "publisher": "MetaDOS LLC",
+ "email": "help@crove.com",
+ "description": "Automatically synchronize booked appointments, attendee contacts, and meeting timelines directly into Crove CRM with Organization and Team attribution.",
+ "isTemplate": false,
+ "__createdUsingCli": true,
+ "dirName": "crovecrm"
+}
diff --git a/packages/app-store/crovecrm/index.ts b/packages/app-store/crovecrm/index.ts
new file mode 100644
index 00000000000..0e90fe5bd34
--- /dev/null
+++ b/packages/app-store/crovecrm/index.ts
@@ -0,0 +1 @@
+export * from "./lib/CrmService";
diff --git a/packages/app-store/crovecrm/lib/CrmService.ts b/packages/app-store/crovecrm/lib/CrmService.ts
new file mode 100644
index 00000000000..89522722f1c
--- /dev/null
+++ b/packages/app-store/crovecrm/lib/CrmService.ts
@@ -0,0 +1,131 @@
+import { CroveCrmService } from "@calcom/features/crove-crm/croveCrmService";
+import type { CalendarEvent, EventBusyDate, IntegrationCalendar } from "@calcom/types/Calendar";
+import type { CredentialPayload } from "@calcom/types/Credential";
+import type { CRM, Contact, ContactCreateInput, CrmEvent } from "@calcom/types/CrmService";
+
+export class CroveCrmIntegrationService implements CRM {
+ private crm: CroveCrmService;
+ private credential: CredentialPayload;
+
+ constructor(credential: CredentialPayload) {
+ this.credential = credential;
+ const key = credential.key as { api_key?: string; api_url?: string } | undefined;
+ this.crm = new CroveCrmService(key?.api_key, key?.api_url);
+ }
+
+ async createEvent(event: CalendarEvent, _contacts?: Contact[]): Promise {
+ const result = await this.crm.syncBookingEvent({
+ triggerEvent: "BOOKING_CREATED",
+ payload: {
+ uid: event.uid || undefined,
+ title: event.title,
+ startTime: event.startTime,
+ endTime: event.endTime,
+ organizer: event.organizer,
+ attendees: event.attendees.map((a) => ({
+ email: a.email,
+ name: a.name,
+ timeZone: a.timeZone,
+ })),
+ },
+ });
+
+ return {
+ id: event.uid || `crovecrm_${Date.now()}`,
+ uid: event.uid || undefined,
+ type: "crovecrm",
+ additionalInfo: result,
+ };
+ }
+
+ async updateEvent(uid: string, event: CalendarEvent): Promise {
+ const result = await this.crm.syncBookingEvent({
+ triggerEvent: "BOOKING_RESCHEDULED",
+ payload: {
+ uid,
+ title: event.title,
+ startTime: event.startTime,
+ endTime: event.endTime,
+ organizer: event.organizer,
+ attendees: event.attendees.map((a) => ({
+ email: a.email,
+ name: a.name,
+ timeZone: a.timeZone,
+ })),
+ },
+ });
+
+ return {
+ id: uid,
+ uid,
+ type: "crovecrm",
+ additionalInfo: result,
+ };
+ }
+
+ async deleteEvent(uid: string, event: CalendarEvent): Promise {
+ await this.crm.syncBookingEvent({
+ triggerEvent: "BOOKING_CANCELLED",
+ payload: {
+ uid,
+ title: event?.title || "Meeting",
+ startTime: event?.startTime || new Date().toISOString(),
+ endTime: event?.endTime,
+ organizer: event?.organizer || { email: "host@crove.com" },
+ attendees: event?.attendees?.map((a) => ({
+ email: a.email,
+ name: a.name,
+ timeZone: a.timeZone,
+ })) || [],
+ },
+ });
+ }
+
+ async getContacts(_options?: { emails: string | string[]; includeOwner?: boolean }): Promise {
+ return [];
+ }
+
+ async createContacts(
+ contactsToCreate: ContactCreateInput[],
+ _organizerEmail?: string
+ ): Promise {
+ const created: Contact[] = [];
+ for (const c of contactsToCreate) {
+ if (c.email) {
+ const res = await this.crm.upsertContact({
+ email: c.email,
+ name: c.name,
+ phone: c.phone || undefined,
+ });
+ created.push({
+ id: res.contactId || c.email,
+ email: c.email,
+ });
+ }
+ }
+ return created;
+ }
+
+ getAppOptions() {
+ return {};
+ }
+
+ async getAvailability(
+ _dateFrom: string,
+ _dateTo: string,
+ _selectedCalendars: IntegrationCalendar[]
+ ): Promise {
+ return [];
+ }
+
+ async listCalendars(_event?: CalendarEvent): Promise {
+ return [];
+ }
+}
+
+export default function BuildCrmService(
+ credential: CredentialPayload,
+ _appOptions?: Record
+): CRM {
+ return new CroveCrmIntegrationService(credential);
+}
diff --git a/packages/app-store/crovecrm/package.json b/packages/app-store/crovecrm/package.json
new file mode 100644
index 00000000000..aa63678d175
--- /dev/null
+++ b/packages/app-store/crovecrm/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@calcom/crovecrm",
+ "version": "0.0.0",
+ "main": "./index.ts",
+ "types": "./index.ts",
+ "private": true,
+ "dependencies": {
+ "@calcom/app-store": "workspace:*",
+ "@calcom/features": "workspace:*",
+ "@calcom/lib": "workspace:*",
+ "zod": "3.25.76"
+ },
+ "devDependencies": {
+ "@calcom/types": "workspace:*"
+ }
+}
diff --git a/packages/app-store/crovecrm/static/icon.svg b/packages/app-store/crovecrm/static/icon.svg
new file mode 100644
index 00000000000..2b4835c4956
--- /dev/null
+++ b/packages/app-store/crovecrm/static/icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/app-store/crovecrm/zod.ts b/packages/app-store/crovecrm/zod.ts
new file mode 100644
index 00000000000..7b284a739bb
--- /dev/null
+++ b/packages/app-store/crovecrm/zod.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
+
+export const appKeysSchema = z.object({
+ api_key: z.string().optional(),
+ api_url: z.string().optional(),
+});
+
+export const appDataSchema = eventTypeAppCardZod;
diff --git a/packages/app-store/hitpay/api/webhook.ts b/packages/app-store/hitpay/api/webhook.ts
index f0e11b63c6f..45b33ade161 100644
--- a/packages/app-store/hitpay/api/webhook.ts
+++ b/packages/app-store/hitpay/api/webhook.ts
@@ -7,6 +7,7 @@ import { distributedTracing } from "@calcom/lib/tracing/factory";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
+import { timingSafeStringsEqual } from "@calcom/lib/webhook-signature";
import prisma from "@calcom/prisma";
import appConfig from "../config.json";
@@ -104,7 +105,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { saltKey } = keyObj;
const signed = generateSignatureArray(saltKey, excluded as ExcludedWebhookReturn);
- if (signed !== obj.hmac) {
+ // Why: `!==` on the HMAC short-circuits at the first differing byte, leaking
+ // timing information that helps forge webhook signatures; compare in constant
+ // time. The typeof check keeps a malformed body (no hmac) on the 400 path since
+ // obj is cast from untrusted JSON.
+ if (typeof obj.hmac !== "string" || !timingSafeStringsEqual(signed, obj.hmac)) {
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
diff --git a/packages/app-store/package.json b/packages/app-store/package.json
index fd0b0436425..cc3c2f34de2 100644
--- a/packages/app-store/package.json
+++ b/packages/app-store/package.json
@@ -44,6 +44,7 @@
},
"devDependencies": {
"@calcom/testing": "workspace:*",
- "@calcom/types": "workspace:*"
+ "@calcom/types": "workspace:*",
+ "typescript": "6.0.3"
}
}
diff --git a/packages/app-store/payment.services.generated.ts b/packages/app-store/payment.services.generated.ts
deleted file mode 100644
index 2e42d632580..00000000000
--- a/packages/app-store/payment.services.generated.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const PaymentServiceMap = {
- alby: import("./alby/lib/PaymentService"),
- btcpayserver: import("./btcpayserver/lib/PaymentService"),
- hitpay: import("./hitpay/lib/PaymentService"),
- "mock-payment-app": import("./mock-payment-app/lib/PaymentService"),
- paypal: import("./paypal/lib/PaymentService"),
- stripepayment: import("./stripepayment/lib/PaymentService"),
-};
diff --git a/packages/app-store/redirect-apps.generated.ts b/packages/app-store/redirect-apps.generated.ts
deleted file mode 100644
index 82edae73fe0..00000000000
--- a/packages/app-store/redirect-apps.generated.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const REDIRECT_APPS = [
- "amie",
- "autocheckin",
- "baa-for-hipaa",
- "bolna",
- "caretta",
- "chatbase",
- "clara",
- "clic",
- "cron",
- "deel",
- "elevenlabs",
- "famulor",
- "fonio-ai",
- "framer",
- "granola",
- "greetmate-ai",
- "lindy",
- "linear",
- "millis-ai",
- "monobot",
- "n8n",
- "pipedream",
- "raycast",
- "retell-ai",
- "synthflow",
- "telli",
- "vimcal",
- "wordpress",
- "zapier",
-] as const;
-export type RedirectApp = (typeof REDIRECT_APPS)[number];
diff --git a/packages/app-store/tsconfig.json b/packages/app-store/tsconfig.json
index 4c69563dde1..4f38d68a3e3 100644
--- a/packages/app-store/tsconfig.json
+++ b/packages/app-store/tsconfig.json
@@ -1,6 +1,15 @@
{
"extends": "@calcom/tsconfig/react-library.json",
- "exclude": ["dist", "types", "build", "**/node_modules/**"],
+ "exclude": [
+ "dist",
+ "types",
+ "build",
+ "**/node_modules/**",
+ "**/*.test.*",
+ "**/__mocks__/*",
+ "**/__tests__/*",
+ "tests"
+ ],
"compilerOptions": {
"baseUrl": ".",
"paths": {
diff --git a/packages/app-store/video.adapters.generated.ts b/packages/app-store/video.adapters.generated.ts
deleted file mode 100644
index f1e0f05ffe7..00000000000
--- a/packages/app-store/video.adapters.generated.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- This file is autogenerated using the command `yarn app-store:build --watch`.
- Don't modify this file manually.
-**/
-export const VideoApiAdapterMap =
- process.env.NEXT_PUBLIC_IS_E2E === "1"
- ? {}
- : {
- dailyvideo: import("./dailyvideo/lib/VideoApiAdapter"),
- huddle01video: import("./huddle01video/lib/VideoApiAdapter"),
- jelly: import("./jelly/lib/VideoApiAdapter"),
- jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"),
- lyra: import("./lyra/lib/VideoApiAdapter"),
- nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"),
- office365video: import("./office365video/lib/VideoApiAdapter"),
- shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"),
- sylapsvideo: import("./sylapsvideo/lib/VideoApiAdapter"),
- tandemvideo: import("./tandemvideo/lib/VideoApiAdapter"),
- webex: import("./webex/lib/VideoApiAdapter"),
- zoomvideo: import("./zoomvideo/lib/VideoApiAdapter"),
- };
diff --git a/packages/config/package.json b/packages/config/package.json
index fe20f2d925a..3cca225b4de 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -13,6 +13,6 @@
},
"devDependencies": {
"@tailwindcss/typography": "0.5.4",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/coss-ui/package.json b/packages/coss-ui/package.json
index c9c8025cd80..243744b1799 100644
--- a/packages/coss-ui/package.json
+++ b/packages/coss-ui/package.json
@@ -54,6 +54,6 @@
"@biomejs/biome": "2.3.10",
"ts-node": "10.9.2",
"tsc-absolute": "1.0.0",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/embeds/embed-core/package.json b/packages/embeds/embed-core/package.json
index 50147ba27e6..a8e4be48373 100644
--- a/packages/embeds/embed-core/package.json
+++ b/packages/embeds/embed-core/package.json
@@ -50,7 +50,7 @@
"npm-run-all": "4.1.5",
"postcss": "8.5.6",
"tailwindcss": "4.1.17",
- "typescript": "5.9.3",
+ "typescript": "6.0.3",
"vite": "6.4.2",
"vite-plugin-environment": "1.1.3"
}
diff --git a/packages/embeds/embed-core/tsconfig.json b/packages/embeds/embed-core/tsconfig.json
index a4f4daf98c0..29a52c9215d 100644
--- a/packages/embeds/embed-core/tsconfig.json
+++ b/packages/embeds/embed-core/tsconfig.json
@@ -2,16 +2,18 @@
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"jsx": "react",
- "target": "ES2015",
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "esnext",
"moduleResolution": "Node",
"baseUrl": ".",
"outDir": "dist",
+ "types": ["node"],
"paths": {
"@calcom/embed-react": ["../embed-react/src"],
"@calcom/embed-snippet": ["../embed-snippet/src"]
}
},
"include": ["src", "env.d.ts", "index.ts", "*.ts"],
- "exclude": ["dist", "build", "**/node_modules/**"]
+ "exclude": ["dist", "build", "**/node_modules/**", "**/*.test.ts", "**/__tests__/**"]
}
diff --git a/packages/embeds/embed-react/package.json b/packages/embeds/embed-react/package.json
index cfb1d20e516..02095d155e2 100644
--- a/packages/embeds/embed-react/package.json
+++ b/packages/embeds/embed-react/package.json
@@ -49,7 +49,7 @@
"@types/react-dom": "18.2.6",
"@vitejs/plugin-react": "5.1.2",
"npm-run-all": "4.1.5",
- "typescript": "5.9.3",
+ "typescript": "6.0.3",
"vite": "6.4.2"
},
"dependencies": {
diff --git a/packages/embeds/embed-react/tsconfig.json b/packages/embeds/embed-react/tsconfig.json
index 38cbe056434..82bd6042b50 100644
--- a/packages/embeds/embed-react/tsconfig.json
+++ b/packages/embeds/embed-react/tsconfig.json
@@ -2,9 +2,11 @@
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
- "target": "ES2015",
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
"moduleResolution": "Node",
"baseUrl": ".",
+ "rootDir": "..",
"declaration": true,
"jsx": "preserve",
"outDir": "dist",
@@ -15,5 +17,5 @@
},
"include": ["src/**/*.ts", "src/**/*.tsx", "env.d.ts", "*.tsx", "*.ts"],
// Exclude "test" because that has `api.test.ts` which imports @calcom/embed-react which needs it to be built using this tsconfig.json first. Excluding it here prevents type-check from validating test folder
- "exclude": ["**/node_modules/**", "test"]
+ "exclude": ["**/node_modules/**", "test", "**/*.test.ts", "**/*.test.tsx"]
}
diff --git a/packages/embeds/embed-snippet/package.json b/packages/embeds/embed-snippet/package.json
index d066eb03505..a5762c4dd39 100644
--- a/packages/embeds/embed-snippet/package.json
+++ b/packages/embeds/embed-snippet/package.json
@@ -25,7 +25,7 @@
],
"types": "./dist/index.d.ts",
"devDependencies": {
- "typescript": "5.9.3",
+ "typescript": "6.0.3",
"vite": "6.4.2"
},
"dependencies": {
diff --git a/packages/embeds/embed-snippet/tsconfig.json b/packages/embeds/embed-snippet/tsconfig.json
index 2444a8dde55..91676ef28bf 100644
--- a/packages/embeds/embed-snippet/tsconfig.json
+++ b/packages/embeds/embed-snippet/tsconfig.json
@@ -2,8 +2,10 @@
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"jsx": "react",
- "target": "ES2015",
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
"baseUrl": ".",
+ "rootDir": "src",
"module": "ESNext",
"declaration": true,
"outDir": "dist",
@@ -12,6 +14,6 @@
"@calcom/embed-react": ["../embed-react/src"]
}
},
- "include": ["."],
+ "include": ["src", "env.d.ts"],
"exclude": ["dist", "build", "**/node_modules/**"]
}
diff --git a/packages/features/auth/lib/identityProviders.test.ts b/packages/features/auth/lib/identityProviders.test.ts
index 9fa642ed977..18280e9e96f 100644
--- a/packages/features/auth/lib/identityProviders.test.ts
+++ b/packages/features/auth/lib/identityProviders.test.ts
@@ -6,7 +6,7 @@ describe("identityProviders", () => {
describe("NEXTAUTH_TO_IDENTITY_PROVIDER", () => {
it("contains exactly the expected mapping keys", () => {
expect(Object.keys(NEXTAUTH_TO_IDENTITY_PROVIDER).sort()).toEqual(
- ["azure-ad", "cal", "google", "saml", "saml-idp"].sort()
+ ["azure-ad", "cal", "dos-id", "google", "oidc", "saml", "saml-idp"].sort()
);
});
});
@@ -20,6 +20,14 @@ describe("identityProviders", () => {
expect(getIdentityProvider("google")).toBe(IdentityProvider.GOOGLE);
});
+ it("maps 'dos-id' to SAML", () => {
+ expect(getIdentityProvider("dos-id")).toBe(IdentityProvider.SAML);
+ });
+
+ it("maps 'oidc' to SAML", () => {
+ expect(getIdentityProvider("oidc")).toBe(IdentityProvider.SAML);
+ });
+
it("maps 'saml' to SAML", () => {
expect(getIdentityProvider("saml")).toBe(IdentityProvider.SAML);
});
diff --git a/packages/features/auth/lib/identityProviders.ts b/packages/features/auth/lib/identityProviders.ts
index 37d540c0e5e..3024f25ab5d 100644
--- a/packages/features/auth/lib/identityProviders.ts
+++ b/packages/features/auth/lib/identityProviders.ts
@@ -9,6 +9,8 @@ export const NEXTAUTH_TO_IDENTITY_PROVIDER: Record = {
google: IdentityProvider.GOOGLE,
saml: IdentityProvider.SAML,
"saml-idp": IdentityProvider.SAML,
+ "dos-id": IdentityProvider.SAML,
+ oidc: IdentityProvider.SAML,
cal: IdentityProvider.CAL,
};
diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts
index 296e64559fd..6558e836d78 100644
--- a/packages/features/auth/lib/next-auth-options.ts
+++ b/packages/features/auth/lib/next-auth-options.ts
@@ -53,6 +53,7 @@ import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail
import { dub } from "./dub";
import { ErrorCode } from "./ErrorCode";
import CalComAdapter from "./next-auth-custom-adapter";
+import { syncDosOrganizations } from "./syncDosOrganizations";
import { verifyPassword } from "./verifyPassword";
type UserWithProfiles = NonNullable<
@@ -300,7 +301,6 @@ export const CalComCredentialsProvider = CredentialsProvider({
authorize: authorizeCredentials,
});
-const providers: Provider[] = [CalComCredentialsProvider];
type SamlIdpUser = {
id: number;
userId: number;
@@ -313,57 +313,142 @@ type SamlIdpUser = {
samlTenant?: string;
};
-if (IS_GOOGLE_LOGIN_ENABLED) {
- providers.push(
- GoogleProvider({
- clientId: GOOGLE_CLIENT_ID,
- clientSecret: GOOGLE_CLIENT_SECRET,
- allowDangerousEmailAccountLinking: true,
- authorization: {
- params: {
- scope: [...GOOGLE_OAUTH_SCOPES, ...GOOGLE_CALENDAR_SCOPES].join(" "),
- access_type: "offline",
- prompt: "consent",
- },
+export function DosIdProvider(options?: { clientId?: string; clientSecret?: string }): Provider {
+ const oidcClientId = (options?.clientId || process.env.OIDC_CLIENT_ID || "").trim();
+ const oidcClientSecret = (
+ options?.clientSecret ||
+ process.env.OIDC_CLIENT_SECRET ||
+ process.env.CROVE_OAUTH_CLIENT_SECRET ||
+ ""
+ ).trim();
+ const wellKnown =
+ process.env.OIDC_WELL_KNOWN_URL ||
+ "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/.well-known/openid-configuration";
+
+ return {
+ id: "dos-id",
+ name: "DOS.Me ID",
+ type: "oauth",
+ issuer: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1",
+ wellKnown,
+ authorization: {
+ url: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/authorize",
+ params: {
+ scope: "openid profile email offline_access",
},
- })
- );
+ },
+ token: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/token",
+ userinfo: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1/oauth/userinfo",
+ idToken: true,
+ checks: ["pkce", "state"],
+ idTokenSignedResponseAlg: "ES256",
+ // Trust decision: Supabase/DOS.Me only issues tokens for emails confirmed at the IdP, so linking
+ // accounts by email match is accepted here. Removing this flag would break SSO for existing
+ // users whose Cal.diy email matches their IdP email.
+ allowDangerousEmailAccountLinking: true,
+ clientId: oidcClientId,
+ clientSecret: oidcClientSecret,
+ client: {
+ client_id: oidcClientId,
+ client_secret: oidcClientSecret,
+ token_endpoint_auth_method: "client_secret_basic",
+ id_token_signed_response_alg: "ES256",
+ },
+ profile(profile: {
+ sub: string;
+ email?: string;
+ email_verified?: boolean;
+ name?: string;
+ picture?: string;
+ avatar_url?: string;
+ active_org_id?: string | number;
+ organizations?: Array<{ id?: string | number; name?: string; role?: string; slug?: string }>;
+ teams?: Array<{ id?: string | number; org_id?: string | number; name?: string; role?: string; slug?: string }>;
+ }) {
+ return {
+ id: profile.sub,
+ name: profile.name || profile.email?.split("@")[0] || "User",
+ email: profile.email,
+ emailVerified: profile.email_verified ? new Date() : null,
+ image: profile.picture || profile.avatar_url || null,
+ activeOrgId: profile.active_org_id,
+ organizations: profile.organizations,
+ teams: profile.teams,
+ } as unknown as User;
+ },
+ options: {
+ clientId: oidcClientId,
+ clientSecret: oidcClientSecret,
+ },
+ } as unknown as Provider;
}
-if (OUTLOOK_LOGIN_ENABLED && OUTLOOK_CLIENT_ID && OUTLOOK_CLIENT_SECRET) {
- providers.push(
- AzureADProvider({
- clientId: OUTLOOK_CLIENT_ID,
- clientSecret: OUTLOOK_CLIENT_SECRET,
- allowDangerousEmailAccountLinking: true,
- authorization: {
- params: {
- scope: ["openid", "profile", "email", ...MICROSOFT_CALENDAR_SCOPES].join(" "),
- prompt: "consent",
+export const getProviders = (): Provider[] => {
+ const currentProviders: Provider[] = [CalComCredentialsProvider];
+
+ const oidcId = (process.env.OIDC_CLIENT_ID || "").trim();
+ const oidcSecret = (
+ process.env.OIDC_CLIENT_SECRET ||
+ process.env.CROVE_OAUTH_CLIENT_SECRET ||
+ ""
+ ).trim();
+ // Only register dos-id when the deployment actually configured its credentials, so an
+ // unconfigured deployment gets no dos-id endpoints at all.
+ if (oidcId && oidcSecret) {
+ currentProviders.push(DosIdProvider({ clientId: oidcId, clientSecret: oidcSecret }));
+ }
+
+ if (IS_GOOGLE_LOGIN_ENABLED) {
+ currentProviders.push(
+ GoogleProvider({
+ clientId: GOOGLE_CLIENT_ID,
+ clientSecret: GOOGLE_CLIENT_SECRET,
+ allowDangerousEmailAccountLinking: true,
+ authorization: {
+ params: {
+ scope: [...GOOGLE_OAUTH_SCOPES, ...GOOGLE_CALENDAR_SCOPES].join(" "),
+ access_type: "offline",
+ prompt: "consent",
+ },
},
- },
- // Azure AD returns base64-encoded picture data (~9KB) that bloats the JWT cookie.
- // we exclude it here and fetch the profile photo separately via Microsoft Graph API.
- profile(profile) {
- return {
- id: profile.sub,
- name: profile.name,
- email: profile.email,
- image: null,
- };
- },
+ })
+ );
+ }
+
+ if (OUTLOOK_LOGIN_ENABLED && OUTLOOK_CLIENT_ID && OUTLOOK_CLIENT_SECRET) {
+ currentProviders.push(
+ AzureADProvider({
+ clientId: OUTLOOK_CLIENT_ID,
+ clientSecret: OUTLOOK_CLIENT_SECRET,
+ allowDangerousEmailAccountLinking: true,
+ authorization: {
+ params: {
+ scope: ["openid", "profile", "email", ...MICROSOFT_CALENDAR_SCOPES].join(" "),
+ prompt: "consent",
+ },
+ },
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: null,
+ };
+ },
+ })
+ );
+ }
+
+ currentProviders.push(
+ EmailProvider({
+ type: "email",
+ maxAge: 10 * 60 * 60,
+ sendVerificationRequest: async (props) => (await import("./sendVerificationRequest")).default(props),
})
);
-}
-providers.push(
- EmailProvider({
- type: "email",
- maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
- // Here we setup the sendVerificationRequest that calls the email template with the identifier (email) and token to verify.
- sendVerificationRequest: async (props) => (await import("./sendVerificationRequest")).default(props),
- })
-);
+ return currentProviders;
+};
function isNumber(n: string) {
return !Number.isNaN(parseFloat(n)) && !Number.isNaN(+n);
@@ -413,7 +498,7 @@ export const getOptions = ({
verifyRequest: "/auth/verify",
// newUser: "/auth/new", // New users will be directed here on first sign in (leave the property out if not of interest)
},
- providers,
+ providers: getProviders(),
callbacks: {
async jwt({
// Always available but with a little difference in value
@@ -428,18 +513,6 @@ export const getOptions = ({
account,
}) {
log.debug("callbacks:jwt", safeStringify({ token, user, account, trigger, session }));
- // The data available in 'session' depends on what data was supplied in update method call of session
- if (trigger === "update") {
- return {
- ...token,
- profileId: session?.profileId ?? token.profileId ?? null,
- upId: session?.upId ?? token.upId ?? null,
- locale: session?.locale ?? token.locale ?? "en",
- name: session?.name ?? token.name,
- username: session?.username ?? token.username,
- email: session?.email ?? token.email,
- } as JWT;
- }
const autoMergeIdentities = async () => {
const existingUser = await prisma.user.findFirst({
where: { email: token.email! },
@@ -525,6 +598,54 @@ export const getOptions = ({
: null,
} as JWT;
};
+
+ // The data available in 'session' depends on what data was supplied in update method call of session
+ if (trigger === "update") {
+ // `session` is client-supplied POST data, so identity must never be derived from it.
+ // Re-anchor the token to the DB record of the signed-in user (token.sub) before any
+ // merge, so autoMergeIdentities cannot be steered to another user's account.
+ const previousToken: JWT = { ...token };
+ try {
+ const userIdFromSub = token.sub && isNumber(token.sub) ? Number(token.sub) : null;
+ if (!userIdFromSub) {
+ return token;
+ }
+ const userFromSub = await prisma.user.findUnique({
+ where: { id: userIdFromSub },
+ select: { id: true, email: true },
+ });
+ if (!userFromSub) {
+ return token;
+ }
+ token.email = userFromSub.email;
+ token.locale = session?.locale ?? token.locale ?? "en";
+ token.name = session?.name ?? token.name;
+ token.username = session?.username ?? token.username;
+
+ const requestedUpId = session?.upId ?? null;
+ if (requestedUpId) {
+ const requestedProfile = await ProfileRepository.findByUpIdWithAuth(
+ requestedUpId,
+ userFromSub.id
+ );
+ if (requestedProfile) {
+ token.upId = requestedUpId;
+ token.profileId = requestedProfile.id ?? token.profileId ?? null;
+ } else {
+ log.warn(
+ "callbacks:jwt:update - ignoring profile switch to a profile not owned by the signed-in user",
+ safeStringify({ requestedUpId, userId: userFromSub.id })
+ );
+ }
+ }
+ return await autoMergeIdentities();
+ } catch (error) {
+ // A missing/inaccessible profile must not throw and kill the session; fall back
+ // to the previous token instead (non-fatal).
+ log.error("callbacks:jwt:update - profile merge failed, keeping previous session", error);
+ return previousToken;
+ }
+ }
if (!user) {
return await autoMergeIdentities();
}
@@ -941,6 +1062,20 @@ export const getOptions = ({
log.error("Error while linking account of already existing user", safeStringify(error));
}
}
+ if (account.provider === "dos-id" || account.provider === "oidc") {
+ const userClaims = user as unknown as {
+ organizations?: Array<{ id?: string | number; name?: string; role?: string; slug?: string }>;
+ teams?: Array<{ id?: string | number; org_id?: string | number; name?: string; role?: string; slug?: string }>;
+ activeOrgId?: string | number;
+ };
+ await syncDosOrganizations(
+ existingUser.id,
+ userClaims?.organizations,
+ userClaims?.teams,
+ userClaims?.activeOrgId
+ );
+ }
+
if (existingUser.twoFactorEnabled && existingUser.identityProvider === idP) {
return loginWithTotp(existingUser.email);
} else {
@@ -990,6 +1125,20 @@ export const getOptions = ({
if (existingUserWithEmail) {
// if self-hosted then we can allow auto-merge of identity providers if email is verified
if (isVerified && existingUserWithEmail.identityProvider !== IdentityProvider.CAL) {
+ if (account.provider === "dos-id" || account.provider === "oidc") {
+ const userClaims = user as unknown as {
+ organizations?: Array<{ id?: string | number; name?: string; role?: string; slug?: string }>;
+ teams?: Array<{ id?: string | number; org_id?: string | number; name?: string; role?: string; slug?: string }>;
+ activeOrgId?: string | number;
+ };
+ await syncDosOrganizations(
+ existingUserWithEmail.id,
+ userClaims?.organizations,
+ userClaims?.teams,
+ userClaims?.activeOrgId
+ );
+ }
+
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
@@ -1048,6 +1197,20 @@ export const getOptions = ({
},
});
+ if (account.provider === "dos-id" || account.provider === "oidc") {
+ const userClaims = user as unknown as {
+ organizations?: Array<{ id?: string | number; name?: string; role?: string; slug?: string }>;
+ teams?: Array<{ id?: string | number; org_id?: string | number; name?: string; role?: string; slug?: string }>;
+ activeOrgId?: string | number;
+ };
+ await syncDosOrganizations(
+ existingUserWithEmail.id,
+ userClaims?.organizations,
+ userClaims?.teams,
+ userClaims?.activeOrgId
+ );
+ }
+
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
@@ -1142,6 +1305,20 @@ export const getOptions = ({
await updateProfilePhotoMicrosoft(account.access_token, newUser.id);
}
+ if (account.provider === "dos-id" || account.provider === "oidc") {
+ const userClaims = user as unknown as {
+ organizations?: Array<{ id?: string | number; name?: string; role?: string; slug?: string }>;
+ teams?: Array<{ id?: string | number; org_id?: string | number; name?: string; role?: string; slug?: string }>;
+ activeOrgId?: string | number;
+ };
+ await syncDosOrganizations(
+ newUser.id,
+ userClaims?.organizations,
+ userClaims?.teams,
+ userClaims?.activeOrgId
+ );
+ }
+
if (newUser.twoFactorEnabled) {
return loginWithTotp(newUser.email);
} else {
diff --git a/packages/features/auth/lib/syncDosOrganizations.ts b/packages/features/auth/lib/syncDosOrganizations.ts
new file mode 100644
index 00000000000..6d0d3ae51b3
--- /dev/null
+++ b/packages/features/auth/lib/syncDosOrganizations.ts
@@ -0,0 +1,337 @@
+import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
+import slugify from "@calcom/lib/slugify";
+import prisma from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+export interface DosOrgClaim {
+ id?: string | number;
+ name?: string;
+ role?: string;
+ slug?: string;
+}
+
+export interface DosTeamClaim {
+ id?: string | number;
+ org_id?: string | number;
+ name?: string;
+ role?: string;
+ slug?: string;
+}
+
+/**
+ * Reads an id from team metadata (e.g. dosOrgId/dosTeamId). Returns "" when unset or empty,
+ * so a slug fallback can distinguish "claimed by a real DOS id" from "never claimed".
+ */
+const readMetadataId = (metadata: unknown, key: "dosOrgId" | "dosTeamId"): string => {
+ if (typeof metadata !== "object" || metadata === null) return "";
+ const value = (metadata as Record)[key];
+ return typeof value === "string" || typeof value === "number" ? String(value) : "";
+};
+
+export async function syncDosOrganizations(
+ userId: number,
+ organizations?: DosOrgClaim[],
+ teams?: DosTeamClaim[],
+ activeOrgId?: string | number
+): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { id: true, email: true, username: true, organizationId: true },
+ });
+
+ if (!user) return;
+
+ let orgList = organizations;
+ let teamList = teams;
+
+ // Fallback to querying Supabase tables directly if claims were not injected into token
+ if (!orgList || !Array.isArray(orgList) || orgList.length === 0) {
+ try {
+ const dbOrgs = await prisma.$queryRaw`
+ SELECT om.org_id as id, o.name, o.slug, om.role
+ FROM public.org_members om
+ JOIN public.organizations o ON om.org_id = o.id
+ JOIN auth.users u ON om.user_id = u.id
+ WHERE u.email = ${user.email}
+ `;
+ if (dbOrgs && Array.isArray(dbOrgs) && dbOrgs.length > 0) {
+ orgList = dbOrgs;
+ }
+ } catch {
+ // ignore if table does not exist
+ }
+ }
+
+ if (!teamList || !Array.isArray(teamList) || teamList.length === 0) {
+ try {
+ // Check for public.team_members / public.teams (or fallback to public.project_members / public.projects)
+ const dbTeams = await prisma.$queryRaw`
+ SELECT tm.team_id as id, t.org_id, t.name, t.slug, tm.role
+ FROM public.team_members tm
+ JOIN public.teams t ON tm.team_id = t.id
+ JOIN auth.users u ON tm.user_id = u.id
+ WHERE u.email = ${user.email}
+ `;
+ if (dbTeams && Array.isArray(dbTeams) && dbTeams.length > 0) {
+ teamList = dbTeams;
+ }
+ } catch {
+ try {
+ const dbProjects = await prisma.$queryRaw`
+ SELECT pm.project_id as id, p.org_id, p.name, p.slug, pm.role
+ FROM public.project_members pm
+ JOIN public.projects p ON pm.project_id = p.id
+ JOIN auth.users u ON pm.user_id = u.id
+ WHERE u.email = ${user.email}
+ `;
+ if (dbProjects && Array.isArray(dbProjects) && dbProjects.length > 0) {
+ teamList = dbProjects;
+ }
+ } catch {
+ // ignore fallback errors
+ }
+ }
+ }
+
+ const orgIdToDbTeamId = new Map();
+
+ // 1. Sync Organizations (Top-level Organization Teams)
+ if (orgList && Array.isArray(orgList) && orgList.length > 0) {
+ for (const org of orgList) {
+ if (!org.id && !org.name) continue;
+
+ const orgId = String(org.id || "");
+ const orgName = org.name || "Default Organization";
+ const orgSlug = org.slug ? slugify(org.slug) : slugify(orgName);
+
+ let orgTeam = orgId
+ ? await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ metadata: { path: ["dosOrgId"], equals: orgId },
+ },
+ select: { id: true, metadata: true },
+ })
+ : await prisma.team.findFirst({
+ where: {
+ isOrganization: true,
+ slug: orgSlug,
+ },
+ select: { id: true, metadata: true },
+ });
+
+ // Slug fallback: a slug match that is already owned by any DOS org id must never be
+ // adopted (it may belong to a different DOS org) — skip the claim instead.
+ if (!orgId && orgTeam && readMetadataId(orgTeam.metadata, "dosOrgId")) {
+ continue;
+ }
+
+ if (!orgTeam) {
+ let uniqueSlug = orgSlug;
+ const existingSlugTeam = await prisma.team.findFirst({
+ where: { slug: uniqueSlug },
+ select: { id: true },
+ });
+
+ if (existingSlugTeam) {
+ uniqueSlug = `${orgSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ orgTeam = await prisma.team.create({
+ data: {
+ name: orgName,
+ slug: uniqueSlug,
+ isOrganization: true,
+ metadata: {
+ dosOrgId: orgId,
+ },
+ },
+ select: { id: true, metadata: true },
+ });
+ } else {
+ // Ensure dosOrgId is in metadata
+ await prisma.team.update({
+ where: { id: orgTeam.id },
+ data: {
+ name: orgName,
+ metadata: {
+ ...(typeof orgTeam.metadata === "object" && orgTeam.metadata ? orgTeam.metadata : {}),
+ dosOrgId: orgId,
+ },
+ },
+ });
+ }
+
+ if (orgId) {
+ orgIdToDbTeamId.set(orgId, orgTeam.id);
+ }
+
+ // Elevated roles are only trusted when the claim's verified id matches the org's own
+ // dosOrgId; slug-fallback matches (unverified org identity) are capped at MEMBER.
+ const orgRoleVerified = orgId !== "" && readMetadataId(orgTeam.metadata, "dosOrgId") === orgId;
+ const rawRole = (org.role || "").toUpperCase();
+ const membershipRole = !orgRoleVerified
+ ? MembershipRole.MEMBER
+ : rawRole === "OWNER"
+ ? MembershipRole.OWNER
+ : rawRole === "ADMIN" || rawRole === "LEAD"
+ ? MembershipRole.ADMIN
+ : MembershipRole.MEMBER;
+
+ await prisma.membership.upsert({
+ where: {
+ userId_teamId: {
+ userId: user.id,
+ teamId: orgTeam.id,
+ },
+ },
+ create: {
+ userId: user.id,
+ teamId: orgTeam.id,
+ role: membershipRole,
+ accepted: true,
+ },
+ update: {
+ role: membershipRole,
+ accepted: true,
+ },
+ });
+
+ const orgUsername = user.username || user.email.split("@")[0];
+ await prisma.profile.upsert({
+ create: {
+ uid: ProfileRepository.generateProfileUid(),
+ userId: user.id,
+ organizationId: orgTeam.id,
+ username: orgUsername,
+ },
+ update: {
+ username: orgUsername,
+ },
+ where: {
+ userId_organizationId: {
+ userId: user.id,
+ organizationId: orgTeam.id,
+ },
+ },
+ });
+
+ if (!user.organizationId) {
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { organizationId: orgTeam.id },
+ });
+ user.organizationId = orgTeam.id;
+ }
+ }
+ }
+
+ // 2. Sync Sub-Teams under Organizations (Department / Team Hierarchy)
+ if (teamList && Array.isArray(teamList) && teamList.length > 0) {
+ for (const subTeam of teamList) {
+ if (!subTeam.id && !subTeam.name) continue;
+
+ const subTeamId = String(subTeam.id || "");
+ const subTeamName = subTeam.name || "Default Team";
+ const subTeamSlug = subTeam.slug ? slugify(subTeam.slug) : slugify(subTeamName);
+ const parentOrgDosId = subTeam.org_id ? String(subTeam.org_id) : undefined;
+ const parentOrgDbId = parentOrgDosId ? orgIdToDbTeamId.get(parentOrgDosId) : undefined;
+
+ let childTeam = subTeamId
+ ? await prisma.team.findFirst({
+ where: {
+ isOrganization: false,
+ metadata: { path: ["dosTeamId"], equals: subTeamId },
+ },
+ select: { id: true, parentId: true, metadata: true },
+ })
+ : await prisma.team.findFirst({
+ where: {
+ isOrganization: false,
+ slug: subTeamSlug,
+ ...(parentOrgDbId ? { parentId: parentOrgDbId } : {}),
+ },
+ select: { id: true, parentId: true, metadata: true },
+ });
+
+ // Slug fallback: same guard as orgs — never adopt a sub-team that is already owned
+ // by any DOS team id; skip the claim instead.
+ if (!subTeamId && childTeam && readMetadataId(childTeam.metadata, "dosTeamId")) {
+ continue;
+ }
+
+ if (!childTeam) {
+ let uniqueSlug = subTeamSlug;
+ const existingSlugTeam = await prisma.team.findFirst({
+ where: { slug: uniqueSlug, ...(parentOrgDbId ? { parentId: parentOrgDbId } : {}) },
+ select: { id: true },
+ });
+
+ if (existingSlugTeam) {
+ uniqueSlug = `${subTeamSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ childTeam = await prisma.team.create({
+ data: {
+ name: subTeamName,
+ slug: uniqueSlug,
+ isOrganization: false,
+ parentId: parentOrgDbId || null,
+ metadata: {
+ dosTeamId: subTeamId,
+ dosOrgId: parentOrgDosId,
+ },
+ },
+ select: { id: true, parentId: true, metadata: true },
+ });
+ } else if (parentOrgDbId && childTeam.parentId !== parentOrgDbId) {
+ // Link to parent organization if previously unassigned
+ await prisma.team.update({
+ where: { id: childTeam.id },
+ data: { parentId: parentOrgDbId },
+ });
+ }
+
+ // Elevated roles only when the claim's verified id matches the team's own dosTeamId;
+ // slug-fallback matches (unverified team identity) are capped at MEMBER.
+ const subTeamRoleVerified =
+ subTeamId !== "" && readMetadataId(childTeam.metadata, "dosTeamId") === subTeamId;
+ const rawRole = (subTeam.role || "").toUpperCase();
+ const teamRole = !subTeamRoleVerified
+ ? MembershipRole.MEMBER
+ : rawRole === "LEAD" || rawRole === "ADMIN" || rawRole === "OWNER"
+ ? MembershipRole.ADMIN
+ : MembershipRole.MEMBER;
+
+ await prisma.membership.upsert({
+ where: {
+ userId_teamId: {
+ userId: user.id,
+ teamId: childTeam.id,
+ },
+ },
+ create: {
+ userId: user.id,
+ teamId: childTeam.id,
+ role: teamRole,
+ accepted: true,
+ },
+ update: {
+ role: teamRole,
+ accepted: true,
+ },
+ });
+ }
+ }
+
+ // 3. Set Active Organization if specified in claims
+ if (activeOrgId) {
+ const activeDbOrgId = orgIdToDbTeamId.get(String(activeOrgId));
+ if (activeDbOrgId && activeDbOrgId !== user.organizationId) {
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { organizationId: activeDbOrgId },
+ });
+ }
+ }
+}
diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json
index f97fb9d8ef4..2a7e7e2c58c 100644
--- a/packages/features/auth/package.json
+++ b/packages/features/auth/package.json
@@ -18,7 +18,7 @@
"handlebars": "4.7.9",
"jose": "4.15.9",
"lru-cache": "9.0.3",
- "next-auth": "4.24.13",
+ "next-auth": "4.24.15",
"nodemailer": "7.0.12",
"otplib": "12.0.1"
}
diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts
index c8f2df2e86f..57eea7cad2d 100644
--- a/packages/features/bookings/lib/handleCancelBooking.ts
+++ b/packages/features/bookings/lib/handleCancelBooking.ts
@@ -1,3 +1,4 @@
+import process from "node:process";
import { DailyLocationType } from "@calcom/app-store/constants";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
@@ -21,25 +22,26 @@ import {
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
+import { WorkflowService } from "@calcom/features/workflows/lib/WorkflowService";
+import { getTranslation } from "@calcom/i18n/server";
import { HttpError } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
-import { getTranslation } from "@calcom/i18n/server";
+import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
// TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export
import prisma from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
-
-import { isCancellationReasonRequired } from "./cancellationReason";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { bookingCancelInput } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { z } from "zod";
import { BookingRepository } from "../repositories/BookingRepository";
import { PrismaBookingAttendeeRepository } from "../repositories/PrismaBookingAttendeeRepository";
+import { isCancellationReasonRequired } from "./cancellationReason";
import type {
CancelBookingMeta,
CancelRegularBookingData,
@@ -49,7 +51,6 @@ import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsFo
import { getBookingToDelete } from "./getBookingToDelete";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
import type { IBookingCancelService } from "./interfaces/IBookingCancelService";
-import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown";
const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] });
@@ -80,17 +81,13 @@ type Dependencies = {
async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
const prismaClient = prisma;
- const {
- userRepository,
- bookingRepository,
- bookingReferenceRepository,
- attendeeRepository,
- } = dependencies || {
- userRepository: new UserRepository(prismaClient),
- bookingRepository: new BookingRepository(prismaClient),
- bookingReferenceRepository: new BookingReferenceRepository({ prismaClient }),
- attendeeRepository: new PrismaBookingAttendeeRepository(prismaClient),
- };
+ const { userRepository, bookingRepository, bookingReferenceRepository, attendeeRepository } =
+ dependencies || {
+ userRepository: new UserRepository(prismaClient),
+ bookingRepository: new BookingRepository(prismaClient),
+ bookingReferenceRepository: new BookingReferenceRepository({ prismaClient }),
+ attendeeRepository: new PrismaBookingAttendeeRepository(prismaClient),
+ };
const body = input.bookingData;
const {
id,
@@ -103,12 +100,12 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
skipCancellationReasonValidation = false,
skipCalendarSyncTaskCancellation = false,
} = bookingCancelInput.parse(body);
- let bookingToDelete: BookingToDelete
+ let bookingToDelete: BookingToDelete;
try {
bookingToDelete = await getBookingToDelete(id, uid);
} catch (error) {
- if (isPrismaError(error) && error.code === "P2025") // Record not found
- {
+ if (isPrismaError(error) && error.code === "P2025") {
+ // Record not found
throw new HttpError({
statusCode: 404,
message: "Booking not found.",
@@ -125,7 +122,6 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
arePlatformEmailsEnabled,
} = input;
-
/**
* Important: We prevent cancelling an already cancelled booking.
* A booking could have been CANCELLED due to a reschedule,
@@ -158,7 +154,12 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
isCancellationUserHost
);
- if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
+ if (
+ !platformClientId &&
+ !cancellationReason?.trim() &&
+ isReasonRequired &&
+ !skipCancellationReasonValidation
+ ) {
throw new HttpError({
statusCode: 400,
message: "Cancellation reason is required",
@@ -427,6 +428,19 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) {
}
}
+ // Invalidate any pending workflow reminders attached to the cancelled
+ // booking(s) — including the original booking when a reschedule cancels it —
+ // so a reminder scheduled for the old time/status never fires.
+ // Best-effort: a failure here must not fail the booking cancellation.
+ const workflowService = new WorkflowService(prismaClient);
+ await Promise.all(
+ updatedBookings.map((booking) =>
+ workflowService.cancelRemindersForBooking({ bookingUid: booking.uid }).catch((e) => {
+ log.error(`Error cancelling workflow reminders for booking ${booking.uid}:`, e);
+ })
+ )
+ );
+
/** TODO: Remove this without breaking functionality */
if (bookingToDelete.location === DailyLocationType) {
bookingToDelete.user.credentials.push({
diff --git a/packages/features/brevo/__tests__/brevoService.test.ts b/packages/features/brevo/__tests__/brevoService.test.ts
new file mode 100644
index 00000000000..399ed1b0587
--- /dev/null
+++ b/packages/features/brevo/__tests__/brevoService.test.ts
@@ -0,0 +1,88 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { BrevoService } from "../brevoService";
+
+describe("BrevoService", () => {
+ const TEST_API_KEY = "xkeysib-test-123456789";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ test("should check if configured properly", () => {
+ const unconfigured = new BrevoService("");
+ expect(unconfigured.isConfigured()).toBe(false);
+
+ const configured = new BrevoService(TEST_API_KEY);
+ expect(configured.isConfigured()).toBe(true);
+ });
+
+ test("upsertContact should call Brevo contacts API with formatted attributes", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ status: 201,
+ json: async () => ({ id: 123 }),
+ });
+ global.fetch = mockFetch;
+
+ const brevo = new BrevoService(TEST_API_KEY);
+ const result = await brevo.upsertContact({
+ email: "client@example.com",
+ name: "Alice Nguyen",
+ meetingTitle: "30 Min Discovery",
+ meetingStart: "2026-09-01T10:00:00Z",
+ meetingStatus: "ACCEPTED",
+ timeZone: "Asia/Ho_Chi_Minh",
+ });
+
+ expect(result.success).toBe(true);
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://api.brevo.com/v3/contacts",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ "api-key": TEST_API_KEY,
+ }),
+ body: JSON.stringify({
+ email: "client@example.com",
+ attributes: {
+ FIRSTNAME: "Alice",
+ LASTNAME: "Nguyen",
+ LAST_MEETING_TITLE: "30 Min Discovery",
+ LAST_MEETING_START: "2026-09-01T10:00:00Z",
+ LAST_MEETING_STATUS: "ACCEPTED",
+ TIMEZONE: "Asia/Ho_Chi_Minh",
+ },
+ updateEnabled: true,
+ }),
+ })
+ );
+ });
+
+ test("trackEvent should send event to Brevo tracking endpoint", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 204,
+ });
+ global.fetch = mockFetch;
+
+ const brevo = new BrevoService(TEST_API_KEY);
+ const result = await brevo.trackEvent({
+ eventName: "meeting_booked",
+ email: "client@example.com",
+ properties: {
+ title: "30 Min Discovery",
+ },
+ });
+
+ expect(result.success).toBe(true);
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://api.brevo.com/v3/events",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ "api-key": TEST_API_KEY,
+ }),
+ })
+ );
+ });
+});
diff --git a/packages/features/brevo/brevoService.ts b/packages/features/brevo/brevoService.ts
new file mode 100644
index 00000000000..593f3921a8a
--- /dev/null
+++ b/packages/features/brevo/brevoService.ts
@@ -0,0 +1,154 @@
+export interface BrevoContactInput {
+ email: string;
+ name?: string;
+ firstName?: string;
+ lastName?: string;
+ meetingTitle?: string;
+ meetingStart?: string;
+ meetingStatus?: "ACCEPTED" | "CANCELLED" | "RESCHEDULED" | "PENDING" | string;
+ timeZone?: string;
+ listIds?: number[];
+ customAttributes?: Record;
+}
+
+export interface BrevoEventInput {
+ eventName: "meeting_booked" | "meeting_cancelled" | "meeting_rescheduled" | string;
+ email: string;
+ properties?: Record;
+}
+
+export class BrevoService {
+ private apiKey: string;
+ private baseUrl = "https://api.brevo.com/v3";
+
+ constructor(apiKey?: string) {
+ this.apiKey = apiKey || process.env.CROVE_BREVO_API_KEY || process.env.BREVO_API_KEY || "";
+ }
+
+ public isConfigured(): boolean {
+ return Boolean(this.apiKey && this.apiKey.trim().length > 0);
+ }
+
+ /**
+ * Upsert contact into Brevo CRM with meeting attributes
+ */
+ public async upsertContact(
+ input: BrevoContactInput
+ ): Promise<{ success: boolean; data?: unknown; error?: string }> {
+ if (!this.isConfigured()) {
+ return { success: false, error: "Brevo API key is not configured" };
+ }
+
+ if (!input.email) {
+ return { success: false, error: "Contact email is required" };
+ }
+
+ let firstName = input.firstName;
+ let lastName = input.lastName;
+
+ if (!firstName && input.name) {
+ const parts = input.name.trim().split(/\s+/);
+ firstName = parts[0];
+ lastName = parts.slice(1).join(" ") || undefined;
+ }
+
+ const attributes: Record = {
+ ...(firstName ? { FIRSTNAME: firstName } : {}),
+ ...(lastName ? { LASTNAME: lastName } : {}),
+ ...(input.meetingTitle ? { LAST_MEETING_TITLE: input.meetingTitle } : {}),
+ ...(input.meetingStart ? { LAST_MEETING_START: input.meetingStart } : {}),
+ ...(input.meetingStatus ? { LAST_MEETING_STATUS: input.meetingStatus } : {}),
+ ...(input.timeZone ? { TIMEZONE: input.timeZone } : {}),
+ ...(input.customAttributes || {}),
+ };
+
+ const body: Record = {
+ email: input.email.toLowerCase().trim(),
+ attributes,
+ updateEnabled: true,
+ };
+
+ if (input.listIds && input.listIds.length > 0) {
+ body.listIds = input.listIds;
+ }
+
+ try {
+ const response = await fetch(`${this.baseUrl}/contacts`, {
+ method: "POST",
+ headers: {
+ "api-key": this.apiKey,
+ "Content-Type": "application/json",
+ accept: "application/json",
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ // 201 Created or 204 No Content (when updateEnabled is true) are both successes
+ if (response.status === 201 || response.status === 204) {
+ let data: unknown = null;
+ try {
+ data = await response.json();
+ } catch {
+ data = { status: response.status };
+ }
+ return { success: true, data };
+ }
+
+ // Handle already existing contact error or update
+ const errorJson = (await response.json().catch(() => ({}))) as { message?: string; code?: string };
+ return {
+ success: false,
+ error: errorJson.message || `Brevo API returned status ${response.status}`,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+
+ /**
+ * Track transactional event in Brevo for Marketing Automation workflows
+ */
+ public async trackEvent(
+ input: BrevoEventInput
+ ): Promise<{ success: boolean; data?: unknown; error?: string }> {
+ if (!this.isConfigured()) {
+ return { success: false, error: "Brevo API key is not configured" };
+ }
+
+ const body = {
+ event_name: input.eventName,
+ identifiers: {
+ email_id: input.email.toLowerCase().trim(),
+ },
+ event_properties: input.properties || {},
+ };
+
+ try {
+ const response = await fetch(`${this.baseUrl}/events`, {
+ method: "POST",
+ headers: {
+ "api-key": this.apiKey,
+ "Content-Type": "application/json",
+ accept: "application/json",
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (response.ok || response.status === 204 || response.status === 201) {
+ return { success: true };
+ }
+
+ const errorJson = (await response.json().catch(() => ({}))) as { message?: string };
+ return {
+ success: false,
+ error: errorJson.message || `Brevo Event API returned status ${response.status}`,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+}
diff --git a/packages/features/crove-crm/__tests__/croveCrmService.test.ts b/packages/features/crove-crm/__tests__/croveCrmService.test.ts
new file mode 100644
index 00000000000..63686dde8cd
--- /dev/null
+++ b/packages/features/crove-crm/__tests__/croveCrmService.test.ts
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CroveCrmService } from "../croveCrmService";
+
+describe("CroveCrmService", () => {
+ const TEST_API_KEY = "crm_live_test_123456789";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it("should report isConfigured correctly based on API key", () => {
+ const unconfigured = new CroveCrmService("");
+ expect(unconfigured.isConfigured()).toBe(false);
+
+ const configured = new CroveCrmService(TEST_API_KEY);
+ expect(configured.isConfigured()).toBe(true);
+ });
+
+ it("upsertContact should post structured contact payload with team & org mapping to Crove CRM", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ id: "contact_123" }),
+ });
+ global.fetch = mockFetch;
+
+ const crm = new CroveCrmService(TEST_API_KEY);
+ const result = await crm.upsertContact({
+ email: "lead@example.com",
+ name: "David Nguyen",
+ phone: "+84901234567",
+ timeZone: "Asia/Ho_Chi_Minh",
+ organizationId: "org_987654321",
+ teamId: "team_11223344",
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.contactId).toBe("contact_123");
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://crm.crove.com/api/v1/contacts",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: `Bearer ${TEST_API_KEY}`,
+ "Content-Type": "application/json",
+ }),
+ body: JSON.stringify({
+ email: "lead@example.com",
+ name: "David Nguyen",
+ first_name: "David",
+ last_name: "Nguyen",
+ phone: "+84901234567",
+ timezone: "Asia/Ho_Chi_Minh",
+ organization_id: "org_987654321",
+ team_id: "team_11223344",
+ source: "crove-cal",
+ tags: ["cal-booking", "source:crove-cal"],
+ custom_fields: {},
+ }),
+ })
+ );
+ });
+
+ it("recordBookingActivity should post timeline activity payload to Crove CRM", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 201,
+ json: async () => ({ id: "act_456" }),
+ });
+ global.fetch = mockFetch;
+
+ const crm = new CroveCrmService(TEST_API_KEY);
+ const result = await crm.recordBookingActivity({
+ contactEmail: "lead@example.com",
+ activityType: "meeting_scheduled",
+ bookingUid: "bk_789",
+ title: "Discovery Call 30m",
+ startTime: "2026-09-03T10:00:00Z",
+ organizerEmail: "host@crove.com",
+ organizationId: "org_987654321",
+ teamId: "team_11223344",
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.activityId).toBe("act_456");
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://crm.crove.com/api/v1/activities",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: `Bearer ${TEST_API_KEY}`,
+ }),
+ })
+ );
+ });
+
+ it("syncBookingEvent should handle full booking lifecycle and multiple attendees", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ id: "synced_id" }),
+ });
+ global.fetch = mockFetch;
+
+ const crm = new CroveCrmService(TEST_API_KEY);
+ const result = await crm.syncBookingEvent({
+ triggerEvent: "BOOKING_CREATED",
+ payload: {
+ uid: "booking_abc",
+ title: "Product Demo",
+ startTime: "2026-09-03T14:00:00Z",
+ organizer: { email: "sales@crove.com", name: "Sales Rep" },
+ attendees: [
+ { email: "client1@acme.com", name: "Client One" },
+ { email: "client2@acme.com", name: "Client Two" },
+ ],
+ organizationId: "org_1",
+ teamId: "team_sales",
+ },
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.syncedContacts).toBe(2);
+ // 2 attendees x (1 contact upsert + 1 activity record) = 4 API calls
+ expect(mockFetch).toHaveBeenCalledTimes(4);
+ });
+});
diff --git a/packages/features/crove-crm/croveCrmService.ts b/packages/features/crove-crm/croveCrmService.ts
new file mode 100644
index 00000000000..8683c59b436
--- /dev/null
+++ b/packages/features/crove-crm/croveCrmService.ts
@@ -0,0 +1,213 @@
+import type { CroveCrmActivityInput, CroveCrmContactInput, CroveCrmSyncResult } from "./types";
+
+export class CroveCrmService {
+ private apiKey: string;
+ private baseUrl: string;
+
+ constructor(apiKey?: string, baseUrl?: string) {
+ this.apiKey = (apiKey || process.env.CROVE_CRM_API_KEY || "").trim();
+ this.baseUrl = (baseUrl || process.env.CROVE_CRM_API_URL || "https://crm.crove.com/api/v1").replace(
+ /\/$/,
+ ""
+ );
+ }
+
+ public isConfigured(): boolean {
+ return Boolean(this.apiKey && this.apiKey.length > 0);
+ }
+
+ /**
+ * Create or update a contact in Crove CRM
+ */
+ public async upsertContact(input: CroveCrmContactInput): Promise {
+ if (!this.isConfigured()) {
+ return { success: false, error: "Crove CRM API key is not configured (CROVE_CRM_API_KEY)" };
+ }
+
+ if (!input.email) {
+ return { success: false, error: "Contact email is required" };
+ }
+
+ let firstName = input.firstName;
+ let lastName = input.lastName;
+ if (!firstName && input.name) {
+ const parts = input.name.trim().split(/\s+/);
+ firstName = parts[0];
+ lastName = parts.slice(1).join(" ") || undefined;
+ }
+
+ const payload = {
+ email: input.email.toLowerCase().trim(),
+ name: input.name,
+ first_name: firstName,
+ last_name: lastName,
+ phone: input.phone,
+ timezone: input.timeZone,
+ organization_id: input.organizationId ? String(input.organizationId) : undefined,
+ team_id: input.teamId ? String(input.teamId) : undefined,
+ source: "crove-cal",
+ tags: Array.from(new Set([...(input.tags || []), "cal-booking", "source:crove-cal"])),
+ custom_fields: input.customFields || {},
+ };
+
+ try {
+ const response = await fetch(`${this.baseUrl}/contacts`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.apiKey}`,
+ "Content-Type": "application/json",
+ accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (response.ok || response.status === 200 || response.status === 201) {
+ const json = await response.json().catch(() => ({}));
+ return {
+ success: true,
+ contactId: json.id || json.data?.id,
+ };
+ }
+
+ const errorData = await response.json().catch(() => ({}));
+ return {
+ success: false,
+ error: errorData.message || `Crove CRM API returned status ${response.status}`,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+
+ /**
+ * Record a meeting activity / timeline event in Crove CRM
+ */
+ public async recordBookingActivity(input: CroveCrmActivityInput): Promise {
+ if (!this.isConfigured()) {
+ return { success: false, error: "Crove CRM API key is not configured (CROVE_CRM_API_KEY)" };
+ }
+
+ const payload = {
+ contact_email: input.contactEmail.toLowerCase().trim(),
+ activity_type: input.activityType,
+ booking_uid: input.bookingUid,
+ title: input.title,
+ start_time: input.startTime,
+ end_time: input.endTime,
+ organizer_email: input.organizerEmail,
+ meeting_url: input.meetingUrl,
+ notes: input.notes,
+ organization_id: input.organizationId ? String(input.organizationId) : undefined,
+ team_id: input.teamId ? String(input.teamId) : undefined,
+ metadata: input.metadata || {},
+ };
+
+ try {
+ const response = await fetch(`${this.baseUrl}/activities`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.apiKey}`,
+ "Content-Type": "application/json",
+ accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (response.ok || response.status === 200 || response.status === 201) {
+ const json = await response.json().catch(() => ({}));
+ return {
+ success: true,
+ activityId: json.id || json.data?.id,
+ };
+ }
+
+ const errorData = await response.json().catch(() => ({}));
+ return {
+ success: false,
+ error: errorData.message || `Crove CRM API returned status ${response.status}`,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+
+ /**
+ * Sync complete booking event payload (Booking Created, Rescheduled, Cancelled)
+ */
+ public async syncBookingEvent(event: {
+ triggerEvent: "BOOKING_CREATED" | "BOOKING_RESCHEDULED" | "BOOKING_CANCELLED" | string;
+ payload: {
+ uid?: string;
+ title?: string;
+ eventTitle?: string;
+ startTime?: string;
+ endTime?: string;
+ status?: string;
+ organizer?: { email: string; name?: string };
+ attendees?: Array<{ email: string; name?: string; timeZone?: string; phoneNumber?: string }>;
+ teamId?: string | number;
+ organizationId?: string | number;
+ metadata?: Record;
+ };
+ }): Promise<{ success: boolean; syncedContacts: number; results: CroveCrmSyncResult[] }> {
+ const attendees = event.payload.attendees || [];
+ const meetingTitle = event.payload.eventTitle || event.payload.title || "Meeting";
+ const bookingUid = event.payload.uid || `booking_${Date.now()}`;
+ const organizerEmail = event.payload.organizer?.email || "organizer@crove.com";
+
+ let activityType: CroveCrmActivityInput["activityType"] = "meeting_scheduled";
+ if (event.triggerEvent === "BOOKING_RESCHEDULED") {
+ activityType = "meeting_rescheduled";
+ } else if (event.triggerEvent === "BOOKING_CANCELLED") {
+ activityType = "meeting_cancelled";
+ }
+
+ const results: CroveCrmSyncResult[] = [];
+
+ for (const attendee of attendees) {
+ if (!attendee.email) continue;
+
+ // 1. Upsert contact
+ const contactRes = await this.upsertContact({
+ email: attendee.email,
+ name: attendee.name,
+ phone: attendee.phoneNumber,
+ timeZone: attendee.timeZone,
+ organizationId: event.payload.organizationId,
+ teamId: event.payload.teamId,
+ });
+
+ // 2. Record activity
+ const activityRes = await this.recordBookingActivity({
+ contactEmail: attendee.email,
+ activityType,
+ bookingUid,
+ title: meetingTitle,
+ startTime: event.payload.startTime || new Date().toISOString(),
+ endTime: event.payload.endTime,
+ organizerEmail,
+ organizationId: event.payload.organizationId,
+ teamId: event.payload.teamId,
+ metadata: event.payload.metadata,
+ });
+
+ results.push({
+ success: contactRes.success && activityRes.success,
+ contactId: contactRes.contactId,
+ activityId: activityRes.activityId,
+ error: contactRes.error || activityRes.error,
+ });
+ }
+
+ const successfulSyncs = results.filter((r) => r.success).length;
+ return {
+ success: results.length > 0 ? successfulSyncs === results.length : true,
+ syncedContacts: successfulSyncs,
+ results,
+ };
+ }
+}
diff --git a/packages/features/crove-crm/types.ts b/packages/features/crove-crm/types.ts
new file mode 100644
index 00000000000..4189ff4c08d
--- /dev/null
+++ b/packages/features/crove-crm/types.ts
@@ -0,0 +1,34 @@
+export interface CroveCrmContactInput {
+ email: string;
+ name?: string;
+ firstName?: string;
+ lastName?: string;
+ phone?: string;
+ timeZone?: string;
+ organizationId?: string | number;
+ teamId?: string | number;
+ customFields?: Record;
+ tags?: string[];
+}
+
+export interface CroveCrmActivityInput {
+ contactEmail: string;
+ activityType: "meeting_scheduled" | "meeting_rescheduled" | "meeting_cancelled" | "meeting_completed";
+ bookingUid: string;
+ title: string;
+ startTime: string;
+ endTime?: string;
+ organizerEmail: string;
+ meetingUrl?: string;
+ notes?: string;
+ organizationId?: string | number;
+ teamId?: string | number;
+ metadata?: Record;
+}
+
+export interface CroveCrmSyncResult {
+ success: boolean;
+ contactId?: string;
+ activityId?: string;
+ error?: string;
+}
diff --git a/packages/features/organizations/OrganizationService.ts b/packages/features/organizations/OrganizationService.ts
new file mode 100644
index 00000000000..d5e6780344f
--- /dev/null
+++ b/packages/features/organizations/OrganizationService.ts
@@ -0,0 +1,279 @@
+import { ErrorWithCode } from "@calcom/lib/errors";
+import slugify from "@calcom/lib/slugify";
+import type { Prisma, PrismaClient } from "@calcom/prisma";
+import prisma from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+export interface UpdateOrgInput {
+ orgId: number;
+ userId: number;
+ name?: string;
+ slug?: string;
+ bio?: string;
+ logoUrl?: string;
+ metadata?: Prisma.InputJsonValue;
+ lockEventTypeCreationForUsers?: boolean;
+}
+
+export interface CreateChildTeamInput {
+ orgId: number;
+ userId: number;
+ name: string;
+ slug?: string;
+ description?: string;
+}
+
+export class OrganizationService {
+ private db: PrismaClient;
+
+ constructor(customPrisma?: PrismaClient) {
+ this.db = customPrisma || prisma;
+ }
+
+ /**
+ * Find all organizations where the user is a member or owner
+ */
+ async findUserOrganizations(params: { userId: number }) {
+ const memberships = await this.db.membership.findMany({
+ where: {
+ userId: params.userId,
+ accepted: true,
+ team: {
+ isOrganization: true,
+ },
+ },
+ select: {
+ role: true,
+ team: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ logoUrl: true,
+ bio: true,
+ isOrganization: true,
+ metadata: true,
+ _count: {
+ select: {
+ members: true,
+ children: true,
+ },
+ },
+ children: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ team: {
+ name: "asc",
+ },
+ },
+ });
+
+ return memberships.map((m) => ({
+ ...m.team,
+ userRole: m.role,
+ memberCount: m.team._count.members,
+ teamsCount: m.team._count.children,
+ childTeams: m.team.children,
+ }));
+ }
+
+ /**
+ * Get organization details by ID
+ */
+ async getOrganizationById(params: { orgId: number; userId: number }) {
+ const membership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: params.userId,
+ teamId: params.orgId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (!membership?.accepted) {
+ throw ErrorWithCode.Factory.Forbidden("You do not have permission to view this organization");
+ }
+
+ const org = await this.db.team.findFirst({
+ where: {
+ id: params.orgId,
+ isOrganization: true,
+ },
+ include: {
+ members: {
+ select: {
+ role: true,
+ accepted: true,
+ user: {
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ avatarUrl: true,
+ },
+ },
+ },
+ },
+ children: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ bio: true,
+ members: {
+ select: {
+ userId: true,
+ role: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!org) {
+ throw ErrorWithCode.Factory.NotFound(`Organization with ID ${params.orgId} not found`);
+ }
+
+ return {
+ ...org,
+ userRole: membership?.role || null,
+ isMember: Boolean(membership?.accepted),
+ };
+ }
+
+ /**
+ * Update organization settings (Owner or Admin only)
+ */
+ async updateOrganization(input: UpdateOrgInput) {
+ const membership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.orgId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (
+ !membership?.accepted ||
+ (membership.role !== MembershipRole.OWNER && membership.role !== MembershipRole.ADMIN)
+ ) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "Unauthorized: Only Organization Owners or Admins can update organization settings."
+ );
+ }
+
+ const data: Prisma.TeamUpdateArgs["data"] = {};
+ if (input.name !== undefined) data.name = input.name;
+ if (input.slug !== undefined) data.slug = slugify(input.slug);
+ if (input.bio !== undefined) data.bio = input.bio;
+ if (input.logoUrl !== undefined) data.logoUrl = input.logoUrl;
+ if (input.metadata !== undefined) data.metadata = input.metadata;
+
+ const updated = await this.db.$transaction(async (tx) => {
+ const team = await tx.team.update({
+ where: { id: input.orgId },
+ data,
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ bio: true,
+ logoUrl: true,
+ metadata: true,
+ },
+ });
+
+ if (input.lockEventTypeCreationForUsers !== undefined) {
+ await tx.organizationSettings.upsert({
+ where: { organizationId: input.orgId },
+ update: { lockEventTypeCreationForUsers: input.lockEventTypeCreationForUsers },
+ create: {
+ organizationId: input.orgId,
+ // No real domain is known here; an empty accept-domain keeps domain-based
+ // auto-accept disabled until the organization configures one.
+ orgAutoAcceptEmail: "",
+ lockEventTypeCreationForUsers: input.lockEventTypeCreationForUsers,
+ },
+ });
+ }
+
+ return team;
+ });
+
+ return updated;
+ }
+
+ /**
+ * Create a team inside an organization
+ */
+ async createTeamUnderOrg(input: CreateChildTeamInput) {
+ const orgMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.orgId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (
+ !orgMembership?.accepted ||
+ (orgMembership.role !== MembershipRole.OWNER && orgMembership.role !== MembershipRole.ADMIN)
+ ) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "Unauthorized: You must be an Organization Owner or Admin to create sub-teams."
+ );
+ }
+
+ const baseSlug = input.slug ? slugify(input.slug) : slugify(input.name);
+ let uniqueSlug = baseSlug;
+
+ const existing = await this.db.team.findFirst({
+ where: { slug: uniqueSlug },
+ select: { id: true },
+ });
+
+ if (existing) {
+ uniqueSlug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ const team = await this.db.team.create({
+ data: {
+ name: input.name,
+ slug: uniqueSlug,
+ bio: input.description || null,
+ parentId: input.orgId,
+ isOrganization: false,
+ members: {
+ create: {
+ userId: input.userId,
+ role: MembershipRole.OWNER,
+ accepted: true,
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ parentId: true,
+ },
+ });
+
+ return team;
+ }
+}
diff --git a/packages/features/organizations/__tests__/OrganizationService.test.ts b/packages/features/organizations/__tests__/OrganizationService.test.ts
new file mode 100644
index 00000000000..67659406bd0
--- /dev/null
+++ b/packages/features/organizations/__tests__/OrganizationService.test.ts
@@ -0,0 +1,169 @@
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import { ErrorWithCode } from "@calcom/lib/errors";
+import type { PrismaClient } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { OrganizationService } from "../OrganizationService";
+
+const mockPrisma = {
+ team: {
+ findMany: vi.fn(),
+ findFirst: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ membership: {
+ findMany: vi.fn(),
+ findUnique: vi.fn(),
+ },
+ organizationSettings: {
+ upsert: vi.fn(),
+ },
+ $transaction: vi.fn(),
+};
+
+describe("OrganizationService", () => {
+ let service: OrganizationService;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) =>
+ callback(mockPrisma)
+ );
+ service = new OrganizationService(mockPrisma as unknown as PrismaClient);
+ });
+
+ test("findUserOrganizations should return organizations where user belongs", async () => {
+ mockPrisma.membership.findMany.mockResolvedValue([
+ {
+ role: MembershipRole.OWNER,
+ team: {
+ id: 100,
+ name: "Crove Org",
+ slug: "crove",
+ isOrganization: true,
+ _count: { members: 3, children: 1 },
+ children: [{ id: 101, name: "Sales", slug: "sales" }],
+ },
+ },
+ ]);
+
+ const result = await service.findUserOrganizations({ userId: 1 });
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("Crove Org");
+ expect(result[0].memberCount).toBe(3);
+ expect(result[0].teamsCount).toBe(1);
+ expect(result[0].userRole).toBe(MembershipRole.OWNER);
+ expect(result[0].childTeams).toEqual([{ id: 101, name: "Sales", slug: "sales" }]);
+ expect(result[0]).not.toHaveProperty("members");
+ });
+
+ test("getOrganizationById should reject users without an accepted membership", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.MEMBER,
+ accepted: false,
+ });
+
+ const error = await service.getOrganizationById({ orgId: 100, userId: 2 }).catch((e) => e);
+ expect(error).toBeInstanceOf(ErrorWithCode);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.team.findFirst).not.toHaveBeenCalled();
+ });
+
+ test("getOrganizationById should throw NotFound when the organization does not exist", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.OWNER,
+ accepted: true,
+ });
+ mockPrisma.team.findFirst.mockResolvedValue(null);
+
+ const error = await service.getOrganizationById({ orgId: 999, userId: 1 }).catch((e) => e);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound);
+ });
+
+ test("updateOrganization should reject members without an accepted membership", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({ role: MembershipRole.OWNER });
+
+ const error = await service
+ .updateOrganization({ orgId: 100, userId: 1, name: "New Org" })
+ .catch((e) => e);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ });
+
+ test("updateOrganization should persist lockEventTypeCreationForUsers in organization settings", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.OWNER,
+ accepted: true,
+ });
+ mockPrisma.team.update.mockResolvedValue({ id: 100, name: "Crove Org" });
+ mockPrisma.organizationSettings.upsert.mockResolvedValue({});
+
+ const result = await service.updateOrganization({
+ orgId: 100,
+ userId: 1,
+ lockEventTypeCreationForUsers: true,
+ });
+
+ expect(result.id).toBe(100);
+ expect(mockPrisma.organizationSettings.upsert).toHaveBeenCalledWith({
+ where: { organizationId: 100 },
+ update: { lockEventTypeCreationForUsers: true },
+ create: {
+ organizationId: 100,
+ orgAutoAcceptEmail: "",
+ lockEventTypeCreationForUsers: true,
+ },
+ });
+ });
+
+ test("createTeamUnderOrg should allow org owner to create sub-team", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.OWNER,
+ accepted: true,
+ });
+ mockPrisma.team.findFirst.mockResolvedValue(null);
+ mockPrisma.team.create.mockResolvedValue({
+ id: 102,
+ name: "Customer Success",
+ slug: "cs",
+ parentId: 100,
+ });
+
+ const result = await service.createTeamUnderOrg({
+ orgId: 100,
+ userId: 1,
+ name: "Customer Success",
+ slug: "cs",
+ });
+
+ expect(result.id).toBe(102);
+ expect(mockPrisma.team.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ parentId: 100,
+ isOrganization: false,
+ }),
+ })
+ );
+ });
+
+ test("createTeamUnderOrg should reject unaccepted org members", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.ADMIN,
+ accepted: false,
+ });
+
+ const error = await service
+ .createTeamUnderOrg({
+ orgId: 100,
+ userId: 2,
+ name: "New Sub Team",
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.team.create).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/features/teams/TeamService.ts b/packages/features/teams/TeamService.ts
new file mode 100644
index 00000000000..593b39e2afa
--- /dev/null
+++ b/packages/features/teams/TeamService.ts
@@ -0,0 +1,566 @@
+import { randomBytes } from "node:crypto";
+import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
+import { ErrorWithCode } from "@calcom/lib/errors";
+import slugify from "@calcom/lib/slugify";
+import type { Prisma, PrismaClient } from "@calcom/prisma";
+import prisma from "@calcom/prisma";
+import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
+
+export interface CreateTeamInput {
+ userId: number;
+ name: string;
+ slug?: string;
+ description?: string;
+ parentId?: number | null;
+ isOrganization?: boolean;
+ metadata?: Prisma.InputJsonValue;
+}
+
+export interface UpdateTeamInput {
+ teamId: number;
+ userId: number;
+ name?: string;
+ slug?: string;
+ bio?: string;
+ logoUrl?: string;
+ hideBookATeamMember?: boolean;
+ metadata?: Prisma.InputJsonValue;
+}
+
+export interface InviteMemberInput {
+ teamId: number;
+ userId: number;
+ email: string;
+ role?: MembershipRole;
+ sendEmail?: boolean;
+}
+
+export interface ChangeRoleInput {
+ teamId: number;
+ userId: number;
+ targetUserId: number;
+ role: MembershipRole;
+}
+
+export interface RemoveMemberInput {
+ teamId: number;
+ userId: number;
+ targetUserId: number;
+}
+
+export class TeamService {
+ private db: PrismaClient;
+
+ constructor(customPrisma?: PrismaClient) {
+ this.db = customPrisma || prisma;
+ }
+
+ /**
+ * Find all teams and organizations where user is a member
+ */
+ async findUserTeams(params: { userId: number; includeOrgs?: boolean }) {
+ const where: Prisma.MembershipWhereInput = {
+ userId: params.userId,
+ accepted: true,
+ };
+
+ if (!params.includeOrgs) {
+ where.team = {
+ isOrganization: false,
+ };
+ }
+
+ const memberships = await this.db.membership.findMany({
+ where,
+ select: {
+ role: true,
+ team: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ logoUrl: true,
+ bio: true,
+ hideBookATeamMember: true,
+ isOrganization: true,
+ parentId: true,
+ metadata: true,
+ _count: {
+ select: {
+ members: true,
+ },
+ },
+ eventTypes: {
+ where: { hidden: false },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ length: true,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ team: {
+ name: "asc",
+ },
+ },
+ });
+
+ return memberships.map((m) => ({
+ ...m.team,
+ role: m.role,
+ memberCount: m.team._count.members,
+ }));
+ }
+
+ /**
+ * Get single team details by ID with permission validation
+ */
+ async getTeamById(params: { teamId: number; userId: number }) {
+ const membership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: params.userId,
+ teamId: params.teamId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (!membership?.accepted) {
+ throw ErrorWithCode.Factory.Forbidden("You do not have permission to view this team");
+ }
+
+ const team = await this.db.team.findUnique({
+ where: { id: params.teamId },
+ include: {
+ members: {
+ select: {
+ role: true,
+ accepted: true,
+ user: {
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ avatarUrl: true,
+ },
+ },
+ },
+ },
+ eventTypes: {
+ where: { hidden: false },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ length: true,
+ hidden: true,
+ description: true,
+ },
+ },
+ parent: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw ErrorWithCode.Factory.NotFound(`Team with ID ${params.teamId} not found`);
+ }
+
+ return {
+ ...team,
+ userRole: membership?.role || null,
+ isMember: Boolean(membership?.accepted),
+ };
+ }
+
+ /**
+ * Create a new Team or Sub-Team
+ */
+ async createTeam(input: CreateTeamInput) {
+ if (input.parentId) {
+ const parentMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.parentId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (
+ !parentMembership?.accepted ||
+ (parentMembership.role !== MembershipRole.OWNER && parentMembership.role !== MembershipRole.ADMIN)
+ ) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "You must be an Owner or Admin of the parent team to create a sub-team."
+ );
+ }
+ }
+
+ if (input.isOrganization) {
+ const user = await this.db.user.findUnique({
+ where: { id: input.userId },
+ select: { role: true },
+ });
+
+ if (user?.role !== UserPermissionRole.ADMIN) {
+ throw ErrorWithCode.Factory.Forbidden("Only instance admins can create organizations.");
+ }
+ }
+
+ const baseSlug = input.slug ? slugify(input.slug) : slugify(input.name);
+ let uniqueSlug = baseSlug;
+
+ // Check slug uniqueness
+ const existing = await this.db.team.findFirst({
+ where: { slug: uniqueSlug },
+ select: { id: true },
+ });
+
+ if (existing) {
+ uniqueSlug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
+ }
+
+ const team = await this.db.team.create({
+ data: {
+ name: input.name,
+ slug: uniqueSlug,
+ bio: input.description || null,
+ parentId: input.parentId || null,
+ isOrganization: input.isOrganization || false,
+ metadata: input.metadata || {},
+ members: {
+ create: {
+ userId: input.userId,
+ role: MembershipRole.OWNER,
+ accepted: true,
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ isOrganization: true,
+ parentId: true,
+ },
+ });
+
+ // If this is an organization, create or link Profile
+ if (input.isOrganization) {
+ const user = await this.db.user.findUnique({
+ where: { id: input.userId },
+ select: { username: true, email: true },
+ });
+ const orgUsername = user?.username || user?.email.split("@")[0] || "user";
+
+ await this.db.profile.upsert({
+ create: {
+ uid: ProfileRepository.generateProfileUid(),
+ userId: input.userId,
+ organizationId: team.id,
+ username: orgUsername,
+ },
+ update: {
+ username: orgUsername,
+ },
+ where: {
+ userId_organizationId: {
+ userId: input.userId,
+ organizationId: team.id,
+ },
+ },
+ });
+ }
+
+ return team;
+ }
+
+ /**
+ * Update team attributes (Owner/Admin only)
+ */
+ async updateTeam(input: UpdateTeamInput) {
+ const membership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.teamId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (
+ !membership?.accepted ||
+ (membership.role !== MembershipRole.OWNER && membership.role !== MembershipRole.ADMIN)
+ ) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "Unauthorized: Only Team Owners or Admins can update team settings."
+ );
+ }
+
+ const data: Prisma.TeamUpdateArgs["data"] = {};
+ if (input.name !== undefined) data.name = input.name;
+ if (input.slug !== undefined) data.slug = slugify(input.slug);
+ if (input.bio !== undefined) data.bio = input.bio;
+ if (input.logoUrl !== undefined) data.logoUrl = input.logoUrl;
+ if (input.hideBookATeamMember !== undefined) data.hideBookATeamMember = input.hideBookATeamMember;
+ if (input.metadata !== undefined) data.metadata = input.metadata;
+
+ const updated = await this.db.team.update({
+ where: { id: input.teamId },
+ data,
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ bio: true,
+ logoUrl: true,
+ hideBookATeamMember: true,
+ metadata: true,
+ },
+ });
+
+ return updated;
+ }
+
+ /**
+ * Delete a team (Owner only)
+ */
+ async deleteTeam(params: { teamId: number; userId: number }) {
+ const membership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: params.userId,
+ teamId: params.teamId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (!membership?.accepted || membership.role !== MembershipRole.OWNER) {
+ throw ErrorWithCode.Factory.Forbidden("Unauthorized: Only Team Owners can delete this team.");
+ }
+
+ const deleted = await this.db.team.delete({
+ where: { id: params.teamId },
+ select: { id: true, name: true, slug: true },
+ });
+
+ return deleted;
+ }
+
+ /**
+ * Invite or add member to a team
+ */
+ async inviteMember(input: InviteMemberInput) {
+ const callerMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.teamId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (
+ !callerMembership?.accepted ||
+ (callerMembership.role !== MembershipRole.OWNER && callerMembership.role !== MembershipRole.ADMIN)
+ ) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "Unauthorized: Only Team Owners or Admins can invite new members."
+ );
+ }
+
+ // Only Owners may grant elevated roles; Admins always invite as MEMBER
+ const role =
+ input.role && callerMembership.role === MembershipRole.OWNER ? input.role : MembershipRole.MEMBER;
+
+ // Emails are stored canonical-lowercase; use the unique index instead of an insensitive search
+ const email = input.email.toLowerCase().trim();
+
+ const targetUser = await this.db.user.findUnique({
+ where: { email },
+ select: { id: true, username: true, email: true },
+ });
+
+ if (targetUser) {
+ const existingMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: targetUser.id,
+ teamId: input.teamId,
+ },
+ },
+ });
+
+ if (existingMembership) {
+ throw ErrorWithCode.Factory.BadRequest("User is already a member of this team.");
+ }
+
+ await this.db.membership.create({
+ data: {
+ userId: targetUser.id,
+ teamId: input.teamId,
+ role,
+ accepted: true,
+ },
+ });
+ } else {
+ // New user: persist an invitation so it survives beyond this request
+ await this.db.verificationToken.create({
+ data: {
+ identifier: email,
+ token: randomBytes(32).toString("hex"),
+ expires: new Date(Date.now() + 7 * 24 * 3600 * 1000),
+ teamId: input.teamId,
+ },
+ });
+ }
+
+ // Both branches return the same opaque result so the endpoint cannot enumerate which emails have accounts
+ return { status: "INVITED" };
+ }
+
+ /**
+ * Change member role
+ */
+ async changeMemberRole(input: ChangeRoleInput) {
+ const callerMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.teamId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (!callerMembership?.accepted || callerMembership.role !== MembershipRole.OWNER) {
+ throw ErrorWithCode.Factory.Forbidden("Unauthorized: Only Team Owners can change member roles.");
+ }
+
+ const targetMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.targetUserId,
+ teamId: input.teamId,
+ },
+ },
+ select: { role: true },
+ });
+
+ if (!targetMembership) {
+ throw ErrorWithCode.Factory.NotFound("Membership not found");
+ }
+
+ if (targetMembership.role === MembershipRole.OWNER && input.role !== MembershipRole.OWNER) {
+ const ownerCount = await this.db.membership.count({
+ where: { teamId: input.teamId, role: MembershipRole.OWNER },
+ });
+
+ if (ownerCount <= 1) {
+ throw ErrorWithCode.Factory.BadRequest("Cannot demote the last owner of this team.");
+ }
+ }
+
+ const updated = await this.db.membership.update({
+ where: {
+ userId_teamId: {
+ userId: input.targetUserId,
+ teamId: input.teamId,
+ },
+ },
+ data: {
+ role: input.role,
+ },
+ select: {
+ userId: true,
+ teamId: true,
+ role: true,
+ },
+ });
+
+ return updated;
+ }
+
+ /**
+ * Remove member from team
+ */
+ async removeMember(input: RemoveMemberInput) {
+ const targetMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.targetUserId,
+ teamId: input.teamId,
+ },
+ },
+ select: { role: true },
+ });
+
+ if (!targetMembership) {
+ throw ErrorWithCode.Factory.NotFound("Membership not found");
+ }
+
+ const isSelf = input.userId === input.targetUserId;
+
+ if (!isSelf) {
+ const callerMembership = await this.db.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: input.userId,
+ teamId: input.teamId,
+ },
+ },
+ select: { role: true, accepted: true },
+ });
+
+ if (
+ !callerMembership?.accepted ||
+ (callerMembership.role !== MembershipRole.OWNER && callerMembership.role !== MembershipRole.ADMIN)
+ ) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "Unauthorized: Only Team Owners or Admins can remove other members."
+ );
+ }
+
+ if (callerMembership.role === MembershipRole.ADMIN && targetMembership.role === MembershipRole.OWNER) {
+ throw ErrorWithCode.Factory.Forbidden("Unauthorized: Only Team Owners can remove an Owner.");
+ }
+ }
+
+ if (targetMembership.role === MembershipRole.OWNER) {
+ const ownerCount = await this.db.membership.count({
+ where: { teamId: input.teamId, role: MembershipRole.OWNER },
+ });
+
+ if (ownerCount <= 1) {
+ throw ErrorWithCode.Factory.BadRequest("Cannot remove the last owner of this team.");
+ }
+ }
+
+ await this.db.membership.delete({
+ where: {
+ userId_teamId: {
+ userId: input.targetUserId,
+ teamId: input.teamId,
+ },
+ },
+ });
+
+ return { success: true };
+ }
+}
diff --git a/packages/features/teams/__tests__/TeamService.test.ts b/packages/features/teams/__tests__/TeamService.test.ts
new file mode 100644
index 00000000000..144ce5eaaf3
--- /dev/null
+++ b/packages/features/teams/__tests__/TeamService.test.ts
@@ -0,0 +1,467 @@
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import { ErrorWithCode } from "@calcom/lib/errors";
+import type { PrismaClient } from "@calcom/prisma";
+import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { TeamService } from "../TeamService";
+
+const mockPrisma = {
+ team: {
+ findMany: vi.fn(),
+ findUnique: vi.fn(),
+ findFirst: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ membership: {
+ findMany: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ user: {
+ findUnique: vi.fn(),
+ findFirst: vi.fn(),
+ },
+ profile: {
+ upsert: vi.fn(),
+ },
+ verificationToken: {
+ create: vi.fn(),
+ },
+};
+
+describe("TeamService", () => {
+ let service: TeamService;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ service = new TeamService(mockPrisma as unknown as PrismaClient);
+ });
+
+ test("findUserTeams should query user memberships and format teams list", async () => {
+ mockPrisma.membership.findMany.mockResolvedValue([
+ {
+ role: MembershipRole.OWNER,
+ team: {
+ id: 1,
+ name: "Engineering",
+ slug: "eng",
+ _count: { members: 1 },
+ eventTypes: [{ id: 101, title: "Sprint Planning", slug: "sprint", length: 30 }],
+ },
+ },
+ ]);
+
+ const result = await service.findUserTeams({ userId: 10 });
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("Engineering");
+ expect(result[0].memberCount).toBe(1);
+ expect(result[0].role).toBe(MembershipRole.OWNER);
+ expect(result[0]).not.toHaveProperty("members");
+ });
+
+ test("createTeam should create a team with OWNER membership", async () => {
+ mockPrisma.team.findFirst.mockResolvedValue(null);
+ mockPrisma.team.create.mockResolvedValue({
+ id: 2,
+ name: "Marketing",
+ slug: "marketing",
+ isOrganization: false,
+ });
+
+ const result = await service.createTeam({
+ userId: 10,
+ name: "Marketing",
+ slug: "marketing",
+ });
+
+ expect(result.id).toBe(2);
+ expect(mockPrisma.team.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ name: "Marketing",
+ slug: "marketing",
+ members: {
+ create: expect.objectContaining({
+ userId: 10,
+ role: MembershipRole.OWNER,
+ accepted: true,
+ }),
+ },
+ }),
+ })
+ );
+ });
+
+ test("createTeam should reject sub-team creation without parent membership", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue(null);
+
+ const error = await service.createTeam({ userId: 10, name: "Sub Team", parentId: 5 }).catch((e) => e);
+ expect(error).toBeInstanceOf(ErrorWithCode);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.team.create).not.toHaveBeenCalled();
+ });
+
+ test("createTeam should reject sub-team creation for plain members of the parent team", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.MEMBER,
+ accepted: true,
+ });
+
+ const error = await service.createTeam({ userId: 10, name: "Sub Team", parentId: 5 }).catch((e) => e);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ });
+
+ test("createTeam should allow sub-team creation for accepted parent admins", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.ADMIN,
+ accepted: true,
+ });
+ mockPrisma.team.findFirst.mockResolvedValue(null);
+ mockPrisma.team.create.mockResolvedValue({ id: 3, name: "Sub", slug: "sub", parentId: 5 });
+
+ const result = await service.createTeam({ userId: 10, name: "Sub", parentId: 5 });
+ expect(result.id).toBe(3);
+ });
+
+ test("createTeam should reject organization creation for non-admin users", async () => {
+ mockPrisma.user.findUnique.mockResolvedValue({ role: UserPermissionRole.USER });
+
+ const error = await service.createTeam({ userId: 10, name: "Org", isOrganization: true }).catch((e) => e);
+ expect(error).toBeInstanceOf(ErrorWithCode);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.team.create).not.toHaveBeenCalled();
+ });
+
+ test("createTeam should allow organization creation for instance admins", async () => {
+ mockPrisma.user.findUnique.mockResolvedValue({
+ role: UserPermissionRole.ADMIN,
+ username: "admin",
+ email: "admin@crove.com",
+ });
+ mockPrisma.team.findFirst.mockResolvedValue(null);
+ mockPrisma.team.create.mockResolvedValue({ id: 4, name: "Org", slug: "org", isOrganization: true });
+ mockPrisma.profile.upsert.mockResolvedValue({});
+
+ const result = await service.createTeam({ userId: 10, name: "Org", isOrganization: true });
+ expect(result.id).toBe(4);
+ });
+
+ test("getTeamById should reject users without an accepted membership before fetching the team", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.MEMBER,
+ accepted: false,
+ });
+
+ const error = await service.getTeamById({ teamId: 1, userId: 20 }).catch((e) => e);
+ expect(error).toBeInstanceOf(ErrorWithCode);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.team.findUnique).not.toHaveBeenCalled();
+ });
+
+ test("getTeamById should throw NotFound when the team does not exist", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.MEMBER,
+ accepted: true,
+ });
+ mockPrisma.team.findUnique.mockResolvedValue(null);
+
+ const error = await service.getTeamById({ teamId: 999, userId: 10 }).catch((e) => e);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound);
+ });
+
+ test("updateTeam should reject non-owner/admin updates", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.MEMBER,
+ accepted: true,
+ });
+
+ await expect(
+ service.updateTeam({
+ teamId: 1,
+ userId: 20,
+ name: "Hacked Team",
+ })
+ ).rejects.toThrow("Unauthorized");
+ });
+
+ test("updateTeam should reject unaccepted members even if they are owners", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.OWNER,
+ accepted: false,
+ });
+
+ const error = await service.updateTeam({ teamId: 1, userId: 10, name: "New Name" }).catch((e) => e);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ });
+
+ test("updateTeam should allow owner to update settings", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.OWNER,
+ accepted: true,
+ });
+ mockPrisma.team.update.mockResolvedValue({
+ id: 1,
+ name: "Engineering Updated",
+ slug: "eng-updated",
+ });
+
+ const result = await service.updateTeam({
+ teamId: 1,
+ userId: 10,
+ name: "Engineering Updated",
+ });
+
+ expect(result.name).toBe("Engineering Updated");
+ });
+
+ test("deleteTeam should reject non-owner members", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.ADMIN,
+ accepted: true,
+ });
+
+ const error = await service.deleteTeam({ teamId: 1, userId: 20 }).catch((e) => e);
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.team.delete).not.toHaveBeenCalled();
+ });
+
+ test("deleteTeam should allow an accepted owner to delete the team", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.OWNER,
+ accepted: true,
+ });
+ mockPrisma.team.delete.mockResolvedValue({ id: 1, name: "Eng", slug: "eng" });
+
+ const result = await service.deleteTeam({ teamId: 1, userId: 10 });
+ expect(result.id).toBe(1);
+ });
+
+ test("inviteMember should add an existing user and return an opaque INVITED result", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }) // caller check
+ .mockResolvedValueOnce(null); // target membership check
+
+ mockPrisma.user.findUnique.mockResolvedValue({
+ id: 30,
+ email: "colleague@crove.com",
+ username: "colleague",
+ });
+
+ mockPrisma.membership.create.mockResolvedValue({});
+
+ const result = await service.inviteMember({
+ teamId: 1,
+ userId: 10,
+ email: "colleague@crove.com",
+ role: MembershipRole.MEMBER,
+ });
+
+ expect(result).toEqual({ status: "INVITED" });
+ expect(result).not.toHaveProperty("email");
+ expect(mockPrisma.membership.create).toHaveBeenCalledWith({
+ data: {
+ userId: 30,
+ teamId: 1,
+ role: MembershipRole.MEMBER,
+ accepted: true,
+ },
+ });
+ expect(mockPrisma.verificationToken.create).not.toHaveBeenCalled();
+ });
+
+ test("inviteMember should clamp the requested role to MEMBER for admin callers", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.ADMIN, accepted: true })
+ .mockResolvedValueOnce(null);
+
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 30, email: "colleague@crove.com" });
+ mockPrisma.membership.create.mockResolvedValue({});
+
+ await service.inviteMember({
+ teamId: 1,
+ userId: 10,
+ email: "colleague@crove.com",
+ role: MembershipRole.OWNER,
+ });
+
+ expect(mockPrisma.membership.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ role: MembershipRole.MEMBER,
+ }),
+ });
+ });
+
+ test("inviteMember should persist a verification token invitation for new users", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValueOnce({
+ role: MembershipRole.OWNER,
+ accepted: true,
+ });
+ mockPrisma.user.findUnique.mockResolvedValue(null);
+ mockPrisma.verificationToken.create.mockResolvedValue({});
+
+ const result = await service.inviteMember({
+ teamId: 1,
+ userId: 10,
+ email: "NewUser@Crove.com",
+ });
+
+ expect(result).toEqual({ status: "INVITED" });
+ expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
+ where: { email: "newuser@crove.com" },
+ select: { id: true, username: true, email: true },
+ });
+ expect(mockPrisma.verificationToken.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ identifier: "newuser@crove.com",
+ teamId: 1,
+ }),
+ });
+ expect(mockPrisma.membership.create).not.toHaveBeenCalled();
+ });
+
+ test("inviteMember should reject users that are already members", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true })
+ .mockResolvedValueOnce({ role: MembershipRole.MEMBER, accepted: true });
+
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 30, email: "colleague@crove.com" });
+
+ const error = await service
+ .inviteMember({
+ teamId: 1,
+ userId: 10,
+ email: "colleague@crove.com",
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.BadRequest);
+ });
+
+ test("inviteMember should reject callers without an accepted membership", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValue({
+ role: MembershipRole.MEMBER,
+ accepted: false,
+ });
+
+ const error = await service
+ .inviteMember({
+ teamId: 1,
+ userId: 20,
+ email: "someone@crove.com",
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ });
+
+ test("changeMemberRole should reject demoting the last owner", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }) // caller
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER }); // target
+ mockPrisma.membership.count.mockResolvedValue(1);
+
+ const error = await service
+ .changeMemberRole({
+ teamId: 1,
+ userId: 10,
+ targetUserId: 10,
+ role: MembershipRole.MEMBER,
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.BadRequest);
+ expect(mockPrisma.membership.update).not.toHaveBeenCalled();
+ });
+
+ test("changeMemberRole should throw NotFound for a missing target membership", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true })
+ .mockResolvedValueOnce(null);
+
+ const error = await service
+ .changeMemberRole({
+ teamId: 1,
+ userId: 10,
+ targetUserId: 99,
+ role: MembershipRole.MEMBER,
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound);
+ });
+
+ test("removeMember should allow owner to remove team member", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.MEMBER }) // target
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }); // caller
+ mockPrisma.membership.delete.mockResolvedValue({});
+
+ const result = await service.removeMember({
+ teamId: 1,
+ userId: 10,
+ targetUserId: 30,
+ });
+
+ expect(result.success).toBe(true);
+ expect(mockPrisma.membership.delete).toHaveBeenCalledWith({
+ where: {
+ userId_teamId: {
+ userId: 30,
+ teamId: 1,
+ },
+ },
+ });
+ });
+
+ test("removeMember should prevent admins from removing an owner", async () => {
+ mockPrisma.membership.findUnique
+ .mockResolvedValueOnce({ role: MembershipRole.OWNER }) // target
+ .mockResolvedValueOnce({ role: MembershipRole.ADMIN, accepted: true }); // caller
+
+ const error = await service
+ .removeMember({
+ teamId: 1,
+ userId: 20,
+ targetUserId: 10,
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden);
+ expect(mockPrisma.membership.delete).not.toHaveBeenCalled();
+ });
+
+ test("removeMember should prevent removing the last owner", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValueOnce({ role: MembershipRole.OWNER });
+ mockPrisma.membership.count.mockResolvedValue(1);
+
+ const error = await service
+ .removeMember({
+ teamId: 1,
+ userId: 10,
+ targetUserId: 10,
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.BadRequest);
+ expect(mockPrisma.membership.delete).not.toHaveBeenCalled();
+ });
+
+ test("removeMember should throw NotFound when the target membership does not exist", async () => {
+ mockPrisma.membership.findUnique.mockResolvedValueOnce(null);
+
+ const error = await service
+ .removeMember({
+ teamId: 1,
+ userId: 10,
+ targetUserId: 99,
+ })
+ .catch((e) => e);
+
+ expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound);
+ });
+});
diff --git a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts
index c900e9df176..28e88d27c3a 100644
--- a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts
+++ b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts
@@ -5,6 +5,11 @@ import type { PrismaClient } from "@calcom/prisma";
import { DEFAULT_WEBHOOK_VERSION } from "./interface/IWebhookRepository";
import { createWebhookSignature, jsonParse } from "./sendPayload";
+// The cron that drains this table is disabled on this fork (audit HI-10), so backlog
+// growth here is expected until crons are re-enabled; the take below only bounds each
+// drain's fan-out, and the remainder drains on subsequent ticks.
+const MAX_JOBS_PER_TICK = 100;
+
export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
await prisma.webhookScheduledTriggers.deleteMany({
where: {
@@ -20,6 +25,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
lte: dayjs().toDate(),
},
},
+ take: MAX_JOBS_PER_TICK,
select: {
id: true,
jobName: true,
@@ -35,6 +41,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
});
const fetchPromises: Promise[] = [];
+ const dispatchedJobIds: number[] = [];
// run jobs
for (const job of jobsToRun) {
@@ -74,14 +81,21 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
console.error(`Webhook trigger for subscriber url ${job.subscriberUrl} failed with error: ${error}`);
})
);
+ dispatchedJobIds.push(job.id);
+ }
+
+ // Deleting only after every dispatch has settled keeps rows alive if the process dies
+ // mid-flight; the old per-row delete ran before each fetch resolved, so any interrupted
+ // delivery was lost for good.
+ await Promise.allSettled(fetchPromises);
- // clean finished job
- await prisma.webhookScheduledTriggers.delete({
+ if (dispatchedJobIds.length > 0) {
+ await prisma.webhookScheduledTriggers.deleteMany({
where: {
- id: job.id,
+ id: {
+ in: dispatchedJobIds,
+ },
},
});
}
-
- Promise.allSettled(fetchPromises);
}
diff --git a/packages/features/workflows/__tests__/WorkflowService.test.ts b/packages/features/workflows/__tests__/WorkflowService.test.ts
new file mode 100644
index 00000000000..8e2d3678996
--- /dev/null
+++ b/packages/features/workflows/__tests__/WorkflowService.test.ts
@@ -0,0 +1,546 @@
+import { ErrorWithCode } from "@calcom/lib/errors";
+import {
+ MembershipRole,
+ TimeUnit,
+ WorkflowActions,
+ WorkflowMethods,
+ WorkflowTriggerEvents,
+} from "@calcom/prisma/enums";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { WorkflowService } from "../lib/WorkflowService";
+
+describe("WorkflowService", () => {
+ const mockPrisma: any = {
+ membership: {
+ findFirst: vi.fn(),
+ },
+ eventType: {
+ count: vi.fn(),
+ },
+ workflow: {
+ findMany: vi.fn(),
+ findFirst: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ workflowStep: {
+ deleteMany: vi.fn(),
+ },
+ workflowsOnEventTypes: {
+ deleteMany: vi.fn(),
+ },
+ workflowReminder: {
+ create: vi.fn(),
+ findMany: vi.fn(),
+ updateMany: vi.fn(),
+ },
+ booking: {
+ findUnique: vi.fn(),
+ },
+ $transaction: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // mockClear does not drop unconsumed mockResolvedValueOnce entries, so a test that
+ // registers findMany but never triggers the call would leak its value into later tests
+ mockPrisma.workflow.findMany.mockReset();
+ mockPrisma.workflow.findFirst.mockReset();
+ mockPrisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(mockPrisma));
+ mockPrisma.membership.findFirst.mockResolvedValue({ id: 1, role: MembershipRole.OWNER });
+ mockPrisma.eventType.count.mockImplementation(
+ async ({ where }: { where: { id: { in: number[] } } }) => where.id.in.length
+ );
+ mockPrisma.workflow.create.mockReset();
+ mockPrisma.workflow.create.mockResolvedValueOnce({ id: 1 });
+ mockPrisma.booking.findUnique.mockReset();
+ mockPrisma.workflowReminder.updateMany.mockReset();
+ });
+
+ describe("calculateScheduledDate", () => {
+ const startTime = new Date("2026-09-10T10:00:00.000Z");
+ const endTime = new Date("2026-09-10T10:30:00.000Z");
+
+ it("should calculate 24 hours before meeting correctly", () => {
+ const scheduled = WorkflowService.calculateScheduledDate({
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ startTime,
+ endTime,
+ time: 24,
+ timeUnit: TimeUnit.HOUR,
+ });
+
+ expect(scheduled.toISOString()).toBe("2026-09-09T10:00:00.000Z");
+ });
+
+ it("should calculate 1 hour after meeting correctly", () => {
+ const scheduled = WorkflowService.calculateScheduledDate({
+ trigger: WorkflowTriggerEvents.AFTER_EVENT,
+ startTime,
+ endTime,
+ time: 1,
+ timeUnit: TimeUnit.HOUR,
+ });
+
+ expect(scheduled.toISOString()).toBe("2026-09-10T11:30:00.000Z");
+ });
+
+ it("should calculate immediate for NEW_EVENT", () => {
+ const before = Date.now();
+ const scheduled = WorkflowService.calculateScheduledDate({
+ trigger: WorkflowTriggerEvents.NEW_EVENT,
+ startTime,
+ endTime,
+ });
+ const after = Date.now();
+
+ expect(scheduled.getTime()).toBeGreaterThanOrEqual(before);
+ expect(scheduled.getTime()).toBeLessThanOrEqual(after);
+ });
+ });
+
+ describe("createWorkflow", () => {
+ const validStep = {
+ stepNumber: 1,
+ action: WorkflowActions.EMAIL_ATTENDEE,
+ emailSubject: "Reminder: Meeting tomorrow",
+ reminderBody: "Hi {ATTENDEE_NAME}, see you tomorrow!",
+ };
+
+ it("should create workflow with steps and activeOn event types", async () => {
+ const service = new WorkflowService(mockPrisma);
+
+ const result = await service.createWorkflow({
+ userId: 10,
+ teamId: null,
+ input: {
+ name: "24h Email Reminder",
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ time: 24,
+ timeUnit: TimeUnit.HOUR,
+ steps: [validStep],
+ activeOn: [101, 102],
+ },
+ });
+
+ expect(result.id).toBe(1);
+ expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled();
+ expect(mockPrisma.eventType.count).toHaveBeenCalledWith({
+ where: {
+ id: { in: [101, 102] },
+ OR: [{ userId: 10, teamId: null }],
+ },
+ });
+ expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1);
+ expect(mockPrisma.workflow.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ name: "24h Email Reminder",
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ time: 24,
+ timeUnit: TimeUnit.HOUR,
+ user: { connect: { id: 10 } },
+ steps: {
+ create: [
+ expect.objectContaining({
+ action: WorkflowActions.EMAIL_ATTENDEE,
+ emailSubject: "Reminder: Meeting tomorrow",
+ }),
+ ],
+ },
+ activeOn: {
+ create: [{ eventTypeId: 101 }, { eventTypeId: 102 }],
+ },
+ }),
+ })
+ );
+ });
+
+ it("should create team workflow only for accepted ADMIN or OWNER members", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.ADMIN });
+ const service = new WorkflowService(mockPrisma);
+
+ const result = await service.createWorkflow({
+ userId: 10,
+ teamId: 5,
+ input: {
+ name: "Team workflow",
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ steps: [validStep],
+ activeOn: [101],
+ },
+ });
+
+ expect(result.id).toBe(1);
+ expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({
+ where: { userId: 10, teamId: 5, accepted: true },
+ select: { id: true, role: true },
+ });
+ expect(mockPrisma.eventType.count).toHaveBeenCalledWith({
+ where: {
+ id: { in: [101] },
+ OR: [{ userId: 10 }, { teamId: 5 }],
+ },
+ });
+ expect(mockPrisma.workflow.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ team: { connect: { id: 5 } },
+ user: undefined,
+ }),
+ })
+ );
+ });
+
+ it("should throw Forbidden for team members without ADMIN or OWNER role", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER });
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(
+ service.createWorkflow({
+ userId: 10,
+ teamId: 5,
+ input: {
+ name: "Team workflow",
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ steps: [validStep],
+ },
+ })
+ ).rejects.toThrow(/Only team owners and admins/);
+
+ expect(mockPrisma.workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("should throw Forbidden when the user is not an accepted team member", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce(null);
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(
+ service.createWorkflow({
+ userId: 10,
+ teamId: 5,
+ input: {
+ name: "Team workflow",
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ steps: [validStep],
+ },
+ })
+ ).rejects.toThrow(ErrorWithCode);
+
+ expect(mockPrisma.workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("should throw when activeOn references event types the user cannot access", async () => {
+ mockPrisma.eventType.count.mockResolvedValueOnce(1);
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(
+ service.createWorkflow({
+ userId: 10,
+ teamId: null,
+ input: {
+ name: "Personal workflow",
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ steps: [validStep],
+ activeOn: [101, 202],
+ },
+ })
+ ).rejects.toThrow(/Event type not found or not accessible/);
+
+ expect(mockPrisma.workflow.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("getWorkflows", () => {
+ it("should throw Forbidden when the user is not an accepted member of the team", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce(null);
+ mockPrisma.workflow.findMany.mockResolvedValueOnce([]);
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(service.getWorkflows({ userId: 10, teamId: 5 })).rejects.toThrow(
+ /not a member of this team/
+ );
+
+ expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({
+ where: { userId: 10, teamId: 5, accepted: true },
+ select: { id: true, role: true },
+ });
+ expect(mockPrisma.workflow.findMany).not.toHaveBeenCalled();
+ });
+
+ it("should list personal workflows without a membership check", async () => {
+ mockPrisma.workflow.findMany.mockResolvedValueOnce([]);
+ const service = new WorkflowService(mockPrisma);
+
+ await service.getWorkflows({ userId: 10, teamId: null });
+
+ expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled();
+ expect(mockPrisma.workflow.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { userId: 10, teamId: null },
+ })
+ );
+ });
+ });
+
+ describe("getWorkflowById", () => {
+ it("should throw Forbidden when the user is not an accepted member of the team", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce(null);
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(service.getWorkflowById({ id: 1, userId: 10, teamId: 5 })).rejects.toThrow(
+ /not a member of this team/
+ );
+
+ expect(mockPrisma.workflow.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("should throw NotFound when the workflow does not exist or is not accessible", async () => {
+ mockPrisma.workflow.findFirst.mockResolvedValueOnce(null);
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(service.getWorkflowById({ id: 1, userId: 10, teamId: null })).rejects.toThrow(
+ /not found or access denied/
+ );
+ });
+ });
+
+ describe("updateWorkflow", () => {
+ const validStep = {
+ stepNumber: 1,
+ action: WorkflowActions.EMAIL_ATTENDEE,
+ emailSubject: "Reminder",
+ reminderBody: "Hi {ATTENDEE_NAME}",
+ };
+
+ it("should wrap step and activeOn replacement with the update in a transaction scoped to the owner", async () => {
+ mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: 10, teamId: null });
+ mockPrisma.workflow.update.mockResolvedValueOnce({ id: 1 });
+ const service = new WorkflowService(mockPrisma);
+
+ const result = await service.updateWorkflow({
+ id: 1,
+ userId: 10,
+ teamId: null,
+ input: {
+ name: "Renamed",
+ trigger: WorkflowTriggerEvents.AFTER_EVENT,
+ time: 15,
+ timeUnit: TimeUnit.MINUTE,
+ activeOn: [101, 102],
+ steps: [validStep],
+ },
+ });
+
+ expect(result.id).toBe(1);
+ expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1);
+ expect(mockPrisma.workflowStep.deleteMany).toHaveBeenCalledWith({ where: { workflowId: 1 } });
+ expect(mockPrisma.workflowsOnEventTypes.deleteMany).toHaveBeenCalledWith({
+ where: { workflowId: 1 },
+ });
+ expect(mockPrisma.eventType.count).toHaveBeenCalledWith({
+ where: { id: { in: [101, 102] }, OR: [{ userId: 10, teamId: null }] },
+ });
+ expect(mockPrisma.workflow.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 1, userId: 10 },
+ data: expect.objectContaining({
+ name: "Renamed",
+ activeOn: { create: [{ eventTypeId: 101 }, { eventTypeId: 102 }] },
+ }),
+ })
+ );
+ });
+
+ it("should scope team updates to the team and require ADMIN or OWNER", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER });
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(
+ service.updateWorkflow({
+ id: 1,
+ userId: 10,
+ teamId: 5,
+ input: { active: false },
+ })
+ ).rejects.toThrow(/Only team owners and admins/);
+
+ expect(mockPrisma.workflow.update).not.toHaveBeenCalled();
+ expect(mockPrisma.workflow.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("should scope team updates with the teamId in the where clause", async () => {
+ mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: null, teamId: 5 });
+ mockPrisma.workflow.update.mockResolvedValueOnce({ id: 1 });
+ const service = new WorkflowService(mockPrisma);
+
+ await service.updateWorkflow({
+ id: 1,
+ userId: 10,
+ teamId: 5,
+ input: { active: false },
+ });
+
+ expect(mockPrisma.workflow.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 1, teamId: 5 },
+ })
+ );
+ });
+ });
+
+ describe("deleteWorkflow", () => {
+ it("should scope the delete to the team workflow", async () => {
+ mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: null, teamId: 5 });
+ mockPrisma.workflow.delete.mockResolvedValueOnce({ id: 1 });
+ const service = new WorkflowService(mockPrisma);
+
+ await service.deleteWorkflow({ id: 1, userId: 10, teamId: 5 });
+
+ expect(mockPrisma.workflow.delete).toHaveBeenCalledWith({ where: { id: 1, teamId: 5 } });
+ });
+
+ it("should scope the delete to the personal workflow", async () => {
+ mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: 10, teamId: null });
+ mockPrisma.workflow.delete.mockResolvedValueOnce({ id: 1 });
+ const service = new WorkflowService(mockPrisma);
+
+ await service.deleteWorkflow({ id: 1, userId: 10, teamId: null });
+
+ expect(mockPrisma.workflow.delete).toHaveBeenCalledWith({ where: { id: 1, userId: 10 } });
+ });
+
+ it("should throw Forbidden for team members without ADMIN or OWNER role", async () => {
+ mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER });
+ const service = new WorkflowService(mockPrisma);
+
+ await expect(service.deleteWorkflow({ id: 1, userId: 10, teamId: 5 })).rejects.toThrow(
+ /Only team owners and admins/
+ );
+
+ expect(mockPrisma.workflow.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("scheduleRemindersForBooking", () => {
+ it("should find active workflows and create reminder records", async () => {
+ const service = new WorkflowService(mockPrisma);
+ mockPrisma.booking.findUnique.mockResolvedValue({
+ uid: "bk_123456",
+ startTime: new Date("2026-09-10T15:00:00.000Z"),
+ endTime: new Date("2026-09-10T15:30:00.000Z"),
+ status: "ACCEPTED",
+ });
+ // mockResolvedValue (not Once) so a stale once-queue leaked from an earlier test cannot shadow this value
+ mockPrisma.workflow.findMany.mockResolvedValue([
+ {
+ id: 1,
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ time: 1,
+ timeUnit: TimeUnit.HOUR,
+ steps: [
+ { id: 10, stepNumber: 1, action: WorkflowActions.EMAIL_ATTENDEE },
+ { id: 11, stepNumber: 2, action: WorkflowActions.SMS_ATTENDEE },
+ ],
+ },
+ ]);
+ mockPrisma.workflowReminder.create.mockImplementation(({ data }) =>
+ Promise.resolve({ id: Math.random(), ...data })
+ );
+
+ const reminders = await service.scheduleRemindersForBooking({
+ bookingUid: "bk_123456",
+ eventTypeId: 101,
+ startTime: new Date("2026-09-10T15:00:00.000Z"),
+ endTime: new Date("2026-09-10T15:30:00.000Z"),
+ });
+
+ expect(reminders).toHaveLength(2);
+ expect(reminders[0].method).toBe(WorkflowMethods.EMAIL);
+ expect(reminders[0].scheduledDate.toISOString()).toBe("2026-09-10T14:00:00.000Z");
+ expect(reminders[1].method).toBe(WorkflowMethods.SMS);
+ });
+
+ it("should skip scheduling entirely when the booking is CANCELLED", async () => {
+ const service = new WorkflowService(mockPrisma);
+ mockPrisma.booking.findUnique.mockResolvedValue({
+ uid: "bk_cancelled",
+ startTime: new Date("2026-09-10T15:00:00.000Z"),
+ endTime: new Date("2026-09-10T15:30:00.000Z"),
+ status: "CANCELLED",
+ });
+
+ const reminders = await service.scheduleRemindersForBooking({
+ bookingUid: "bk_cancelled",
+ eventTypeId: 101,
+ startTime: new Date("2026-09-10T15:00:00.000Z"),
+ endTime: new Date("2026-09-10T15:30:00.000Z"),
+ });
+
+ expect(reminders).toEqual([]);
+ // Neither workflow lookup nor reminder creation may run for a cancelled booking
+ expect(mockPrisma.workflow.findMany).not.toHaveBeenCalled();
+ expect(mockPrisma.workflowReminder.create).not.toHaveBeenCalled();
+ });
+
+ it("should schedule from the booking row's times, not the caller's stale reference", async () => {
+ const service = new WorkflowService(mockPrisma);
+ // Booking was moved after the caller read it: DB row is the source of truth
+ mockPrisma.booking.findUnique.mockResolvedValue({
+ uid: "bk_moved",
+ startTime: new Date("2026-09-11T09:00:00.000Z"),
+ endTime: new Date("2026-09-11T09:30:00.000Z"),
+ status: "ACCEPTED",
+ });
+ mockPrisma.workflow.findMany.mockResolvedValue([
+ {
+ id: 1,
+ trigger: WorkflowTriggerEvents.BEFORE_EVENT,
+ time: 1,
+ timeUnit: TimeUnit.HOUR,
+ steps: [{ id: 10, stepNumber: 1, action: WorkflowActions.EMAIL_ATTENDEE }],
+ },
+ ]);
+ mockPrisma.workflowReminder.create.mockImplementation(({ data }) =>
+ Promise.resolve({ id: Math.random(), ...data })
+ );
+
+ const reminders = await service.scheduleRemindersForBooking({
+ bookingUid: "bk_moved",
+ eventTypeId: 101,
+ // Stale times from before the reschedule
+ startTime: new Date("2026-09-10T15:00:00.000Z"),
+ endTime: new Date("2026-09-10T15:30:00.000Z"),
+ });
+
+ expect(reminders).toHaveLength(1);
+ // 1 hour before the NEW start time (2026-09-11T09:00Z), not the stale one
+ expect(reminders[0].scheduledDate.toISOString()).toBe("2026-09-11T08:00:00.000Z");
+ });
+ });
+
+ describe("cancelRemindersForBooking", () => {
+ it("should mark all unsent reminders for the booking as cancelled", async () => {
+ const service = new WorkflowService(mockPrisma);
+ mockPrisma.workflowReminder.updateMany.mockResolvedValue({ count: 3 });
+
+ const result = await service.cancelRemindersForBooking({ bookingUid: "bk_old" });
+
+ expect(result).toEqual({ count: 3 });
+ expect(mockPrisma.workflowReminder.updateMany).toHaveBeenCalledWith({
+ where: { bookingUid: "bk_old", scheduled: false, cancelled: false },
+ data: { cancelled: true },
+ });
+ });
+
+ it("should propagate the updateMany result count of zero when nothing was pending", async () => {
+ const service = new WorkflowService(mockPrisma);
+ mockPrisma.workflowReminder.updateMany.mockResolvedValue({ count: 0 });
+
+ const result = await service.cancelRemindersForBooking({ bookingUid: "bk_none" });
+
+ expect(result).toEqual({ count: 0 });
+ });
+ });
+});
diff --git a/packages/features/workflows/lib/WorkflowService.ts b/packages/features/workflows/lib/WorkflowService.ts
new file mode 100644
index 00000000000..a35f526bd47
--- /dev/null
+++ b/packages/features/workflows/lib/WorkflowService.ts
@@ -0,0 +1,526 @@
+import { ErrorWithCode } from "@calcom/lib/errors";
+import type { PrismaClient } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+import {
+ BookingStatus,
+ MembershipRole,
+ TimeUnit,
+ WorkflowActions,
+ WorkflowMethods,
+ WorkflowTemplates,
+ WorkflowTriggerEvents,
+} from "@calcom/prisma/enums";
+
+export interface WorkflowStepInput {
+ id?: number;
+ stepNumber: number;
+ action: WorkflowActions;
+ sendTo?: string | null;
+ reminderBody?: string | null;
+ emailSubject?: string | null;
+ template?: WorkflowTemplates;
+ sender?: string | null;
+ numberRequired?: boolean | null;
+ includeCalendarEvent?: boolean;
+}
+
+export interface CreateWorkflowInput {
+ name: string;
+ trigger: WorkflowTriggerEvents;
+ time?: number | null;
+ timeUnit?: TimeUnit | null;
+ steps: WorkflowStepInput[];
+ activeOn?: number[];
+ isOrganiserEvent?: boolean;
+}
+
+export interface UpdateWorkflowInput extends Partial {
+ active?: boolean;
+}
+
+export class WorkflowService {
+ private prisma: PrismaClient;
+
+ constructor(prisma: PrismaClient) {
+ this.prisma = prisma;
+ }
+
+ private async assertTeamMembership({
+ userId,
+ teamId,
+ requireAdminOwner = false,
+ }: {
+ userId: number;
+ teamId: number;
+ requireAdminOwner?: boolean;
+ }) {
+ const membership = await this.prisma.membership.findFirst({
+ where: { userId, teamId, accepted: true },
+ select: { id: true, role: true },
+ });
+
+ if (!membership) {
+ throw ErrorWithCode.Factory.Forbidden("You are not a member of this team");
+ }
+
+ if (
+ requireAdminOwner &&
+ membership.role !== MembershipRole.ADMIN &&
+ membership.role !== MembershipRole.OWNER
+ ) {
+ throw ErrorWithCode.Factory.Forbidden("Only team owners and admins can manage workflows");
+ }
+ }
+
+ /**
+ * Get all workflows accessible to a user (personal + team workflows)
+ */
+ async getWorkflows({ userId, teamId }: { userId: number; teamId?: number | null }) {
+ if (teamId) {
+ await this.assertTeamMembership({ userId, teamId });
+ }
+
+ const where: Prisma.WorkflowWhereInput = teamId ? { teamId } : { userId, teamId: null };
+
+ return this.prisma.workflow.findMany({
+ where,
+ include: {
+ steps: {
+ orderBy: { stepNumber: "asc" },
+ },
+ activeOn: {
+ include: {
+ eventType: {
+ select: { id: true, title: true, slug: true },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ });
+ }
+
+ /**
+ * Get a single workflow by ID with security validation
+ */
+ async getWorkflowById({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
+ if (teamId) {
+ await this.assertTeamMembership({ userId, teamId });
+ }
+
+ const where: Prisma.WorkflowWhereInput = teamId ? { id, teamId } : { id, userId };
+
+ const workflow = await this.prisma.workflow.findFirst({
+ where,
+ include: {
+ steps: {
+ orderBy: { stepNumber: "asc" },
+ },
+ activeOn: {
+ include: {
+ eventType: {
+ select: { id: true, title: true, slug: true },
+ },
+ },
+ },
+ },
+ });
+
+ if (!workflow) {
+ throw ErrorWithCode.Factory.NotFound(`Workflow with ID ${id} not found or access denied`);
+ }
+
+ return workflow;
+ }
+
+ /**
+ * Create a new automated Workflow with triggers, steps, and active event types
+ */
+ async createWorkflow({
+ userId,
+ teamId,
+ input,
+ }: {
+ userId: number;
+ teamId?: number | null;
+ input: CreateWorkflowInput;
+ }) {
+ if (!input.name || input.name.trim().length === 0) {
+ throw ErrorWithCode.Factory.BadRequest("Workflow name is required");
+ }
+
+ if (!input.steps || input.steps.length === 0) {
+ throw ErrorWithCode.Factory.BadRequest("At least one workflow action step is required");
+ }
+
+ if (teamId) {
+ await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true });
+ }
+
+ return this.prisma.$transaction(async (tx) => {
+ if (input.activeOn && input.activeOn.length > 0) {
+ const owned = await tx.eventType.count({
+ where: {
+ id: { in: input.activeOn },
+ OR: teamId ? [{ userId }, { teamId }] : [{ userId, teamId: null }],
+ },
+ });
+
+ if (owned !== input.activeOn.length) {
+ throw ErrorWithCode.Factory.NotFound("Event type not found or not accessible");
+ }
+ }
+
+ return tx.workflow.create({
+ data: {
+ name: input.name.trim(),
+ trigger: input.trigger,
+ time: input.time || null,
+ timeUnit: input.timeUnit || null,
+ isOrganiserEvent: input.isOrganiserEvent ?? false,
+ active: true,
+ user: teamId ? undefined : { connect: { id: userId } },
+ team: teamId ? { connect: { id: teamId } } : undefined,
+ steps: {
+ create: input.steps.map((step, idx) => ({
+ stepNumber: step.stepNumber || idx + 1,
+ action: step.action,
+ sendTo: step.sendTo || null,
+ reminderBody: step.reminderBody || null,
+ emailSubject: step.emailSubject || null,
+ template: step.template || WorkflowTemplates.REMINDER,
+ sender: step.sender || null,
+ numberRequired: step.numberRequired || null,
+ includeCalendarEvent: step.includeCalendarEvent ?? false,
+ })),
+ },
+ activeOn:
+ input.activeOn && input.activeOn.length > 0
+ ? {
+ create: input.activeOn.map((eventTypeId) => ({
+ eventTypeId,
+ })),
+ }
+ : undefined,
+ },
+ include: {
+ steps: true,
+ activeOn: true,
+ },
+ });
+ });
+ }
+
+ /**
+ * Update an existing workflow
+ */
+ async updateWorkflow({
+ id,
+ userId,
+ teamId,
+ input,
+ }: {
+ id: number;
+ userId: number;
+ teamId?: number | null;
+ input: UpdateWorkflowInput;
+ }) {
+ if (teamId) {
+ await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true });
+ }
+
+ // Validate existence & ownership
+ await this.getWorkflowById({ id, userId, teamId });
+
+ return this.prisma.$transaction(async (tx) => {
+ // Handle steps replacement if provided
+ if (input.steps) {
+ await tx.workflowStep.deleteMany({
+ where: { workflowId: id },
+ });
+ }
+
+ // Handle activeOn event types update if provided
+ if (input.activeOn) {
+ await tx.workflowsOnEventTypes.deleteMany({
+ where: { workflowId: id },
+ });
+
+ if (input.activeOn.length > 0) {
+ const owned = await tx.eventType.count({
+ where: {
+ id: { in: input.activeOn },
+ OR: teamId ? [{ userId }, { teamId }] : [{ userId, teamId: null }],
+ },
+ });
+
+ if (owned !== input.activeOn.length) {
+ throw ErrorWithCode.Factory.NotFound("Event type not found or not accessible");
+ }
+ }
+ }
+
+ return tx.workflow.update({
+ where: { id, ...(teamId ? { teamId } : { userId }) },
+ data: {
+ ...(input.name ? { name: input.name.trim() } : {}),
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
+ ...(input.time !== undefined ? { time: input.time } : {}),
+ ...(input.timeUnit !== undefined ? { timeUnit: input.timeUnit } : {}),
+ ...(input.active !== undefined ? { active: input.active } : {}),
+ ...(input.isOrganiserEvent !== undefined ? { isOrganiserEvent: input.isOrganiserEvent } : {}),
+ ...(input.steps
+ ? {
+ steps: {
+ create: input.steps.map((step, idx) => ({
+ stepNumber: step.stepNumber || idx + 1,
+ action: step.action,
+ sendTo: step.sendTo || null,
+ reminderBody: step.reminderBody || null,
+ emailSubject: step.emailSubject || null,
+ template: step.template || WorkflowTemplates.REMINDER,
+ sender: step.sender || null,
+ numberRequired: step.numberRequired || null,
+ includeCalendarEvent: step.includeCalendarEvent ?? false,
+ })),
+ },
+ }
+ : {}),
+ ...(input.activeOn
+ ? {
+ activeOn: {
+ create: input.activeOn.map((eventTypeId) => ({
+ eventTypeId,
+ })),
+ },
+ }
+ : {}),
+ },
+ include: {
+ steps: { orderBy: { stepNumber: "asc" } },
+ activeOn: {
+ include: {
+ eventType: { select: { id: true, title: true, slug: true } },
+ },
+ },
+ },
+ });
+ });
+ }
+
+ /**
+ * Delete a workflow
+ */
+ async deleteWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
+ if (teamId) {
+ await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true });
+ }
+
+ await this.getWorkflowById({ id, userId, teamId });
+
+ return this.prisma.workflow.delete({
+ where: { id, ...(teamId ? { teamId } : { userId }) },
+ });
+ }
+
+ /**
+ * Duplicate a workflow
+ */
+ async duplicateWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) {
+ if (teamId) {
+ await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true });
+ }
+
+ const original = await this.getWorkflowById({ id, userId, teamId });
+
+ return this.createWorkflow({
+ userId,
+ teamId,
+ input: {
+ name: `${original.name} (Copy)`,
+ trigger: original.trigger,
+ time: original.time,
+ timeUnit: original.timeUnit,
+ isOrganiserEvent: original.isOrganiserEvent,
+ steps: original.steps.map((s) => ({
+ stepNumber: s.stepNumber,
+ action: s.action,
+ sendTo: s.sendTo,
+ reminderBody: s.reminderBody,
+ emailSubject: s.emailSubject,
+ template: s.template,
+ sender: s.sender,
+ numberRequired: s.numberRequired,
+ includeCalendarEvent: s.includeCalendarEvent,
+ })),
+ activeOn: original.activeOn.map((a) => a.eventTypeId),
+ },
+ });
+ }
+
+ /**
+ * Calculate scheduledDate for a workflow trigger based on booking time
+ */
+ static calculateScheduledDate({
+ trigger,
+ startTime,
+ endTime,
+ time,
+ timeUnit,
+ }: {
+ trigger: WorkflowTriggerEvents;
+ startTime: Date;
+ endTime: Date;
+ time?: number | null;
+ timeUnit?: TimeUnit | null;
+ }): Date {
+ const startMs = new Date(startTime).getTime();
+ const endMs = new Date(endTime).getTime();
+
+ if (
+ trigger === WorkflowTriggerEvents.NEW_EVENT ||
+ trigger === WorkflowTriggerEvents.EVENT_CANCELLED ||
+ trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT
+ ) {
+ return new Date(); // Send immediately
+ }
+
+ let offsetMs = 0;
+ if (time && timeUnit) {
+ switch (timeUnit) {
+ case TimeUnit.DAY:
+ offsetMs = time * 24 * 60 * 60 * 1000;
+ break;
+ case TimeUnit.HOUR:
+ offsetMs = time * 60 * 60 * 1000;
+ break;
+ case TimeUnit.MINUTE:
+ offsetMs = time * 60 * 1000;
+ break;
+ }
+ }
+
+ if (trigger === WorkflowTriggerEvents.BEFORE_EVENT) {
+ return new Date(startMs - offsetMs);
+ }
+
+ if (trigger === WorkflowTriggerEvents.AFTER_EVENT) {
+ return new Date(endMs + offsetMs);
+ }
+
+ return new Date();
+ }
+
+ /**
+ * Schedule all workflow reminders for a newly created or rescheduled booking
+ */
+ async scheduleRemindersForBooking({
+ bookingUid,
+ eventTypeId,
+ startTime,
+ endTime,
+ trigger = WorkflowTriggerEvents.BEFORE_EVENT,
+ }: {
+ bookingUid: string;
+ eventTypeId: number;
+ startTime: Date;
+ endTime: Date;
+ trigger?: WorkflowTriggerEvents;
+ }) {
+ // Re-fetch the booking before creating any reminders: the caller may hold a
+ // stale reference from before a reschedule/cancel, and reminders computed
+ // from stale times would fire at the wrong moment (or at all for a booking
+ // that has since been cancelled).
+ const booking = await this.prisma.booking.findUnique({
+ where: { uid: bookingUid },
+ select: { uid: true, startTime: true, endTime: true, status: true },
+ });
+
+ if (!booking || booking.status === BookingStatus.CANCELLED) {
+ return [];
+ }
+
+ // Times on the booking row are the source of truth; caller-passed values
+ // may be outdated if the booking was moved between the caller's read and
+ // this scheduling call.
+ const effectiveStartTime = booking.startTime;
+ const effectiveEndTime = booking.endTime;
+
+ // Find all active workflows associated with this eventType matching trigger
+ const workflows = await this.prisma.workflow.findMany({
+ where: {
+ active: true,
+ trigger,
+ activeOn: {
+ some: { eventTypeId },
+ },
+ },
+ include: {
+ steps: true,
+ },
+ });
+
+ const createdReminders = [];
+
+ for (const wf of workflows) {
+ const scheduledDate = WorkflowService.calculateScheduledDate({
+ trigger: wf.trigger,
+ startTime: effectiveStartTime,
+ endTime: effectiveEndTime,
+ time: wf.time,
+ timeUnit: wf.timeUnit,
+ });
+
+ for (const step of wf.steps) {
+ let method: WorkflowMethods = WorkflowMethods.EMAIL;
+ if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) {
+ method = WorkflowMethods.SMS;
+ } else if (
+ step.action === WorkflowActions.WHATSAPP_ATTENDEE ||
+ step.action === WorkflowActions.WHATSAPP_NUMBER
+ ) {
+ method = WorkflowMethods.WHATSAPP;
+ }
+
+ const reminder = await this.prisma.workflowReminder.create({
+ data: {
+ bookingUid,
+ workflowStepId: step.id,
+ method,
+ scheduledDate,
+ scheduled: false,
+ referenceId: `rem_${bookingUid}_${step.id}_${Date.now()}`,
+ },
+ });
+ createdReminders.push(reminder);
+ }
+ }
+
+ return createdReminders;
+ }
+
+ /**
+ * Cancel all pending (not yet sent) workflow reminders for a booking.
+ *
+ * Must be called whenever a booking is cancelled or rescheduled: on
+ * reschedule the new booking gets its own fresh reminders, while reminders
+ * still attached to the old (now CANCELLED) booking row must never fire.
+ * Uses the `cancelled` flag rather than deletion so the send path keeps a
+ * consistent audit trail of what was invalidated.
+ */
+ async cancelRemindersForBooking({ bookingUid }: { bookingUid: string }) {
+ return this.prisma.workflowReminder.updateMany({
+ where: {
+ bookingUid,
+ // Never touch reminders that were already sent
+ scheduled: false,
+ cancelled: false,
+ },
+ data: {
+ cancelled: true,
+ },
+ });
+ }
+}
+
+export default WorkflowService;
diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json
index daf2add9597..3ad1a3d99a4 100644
--- a/packages/i18n/locales/en/common.json
+++ b/packages/i18n/locales/en/common.json
@@ -1087,6 +1087,10 @@
"dark_event_type_color": "Event type color (dark theme)",
"file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create team",
+ "create_a_team": "Create a team",
+ "no_teams_yet": "No teams yet",
+ "create_team_description": "Create a team to collaborate with others and share event types.",
+ "team_created_successfully": "Team {{teamName}} created successfully",
"name": "Name",
"nameless_team": "Nameless Team",
"oauth_clients": "OAuth Clients",
@@ -1370,6 +1374,7 @@
"account_managed_by_identity_provider_error": "Your account is managed by different identity provider. Try logging in with {{provider}}.",
"user_creation_error": "Error creating a new user. Please try again.",
"signin_with_google": "Sign in with Google",
+ "signin_with_dos_id": "Sign in with DOS.Me ID",
"signin_with_saml": "Sign in with SAML",
"signin_with_saml_oidc": "Sign in with SAML/OIDC",
"continue_with_email": "Continue with Email",
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index 6a2bfb8abf0..52ddc149acf 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -16,6 +16,6 @@
},
"devDependencies": {
"@calcom/tsconfig": "workspace:*",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/lib/__tests__/webhookMonitor.test.ts b/packages/lib/__tests__/webhookMonitor.test.ts
new file mode 100644
index 00000000000..3e3596ed2a3
--- /dev/null
+++ b/packages/lib/__tests__/webhookMonitor.test.ts
@@ -0,0 +1,107 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { webhookMonitor } from "../webhookMonitor";
+
+describe("WebhookMonitor", () => {
+ beforeEach(() => {
+ webhookMonitor.reset();
+ });
+
+ it("should initialize with idle status and zero events", () => {
+ const metrics = webhookMonitor.getMetrics();
+ expect(metrics.status).toBe("idle");
+ expect(metrics.totalEvents).toBe(0);
+ expect(metrics.successCount).toBe(0);
+ expect(metrics.failureCount).toBe(0);
+ expect(metrics.successRate).toBe(100);
+ expect(metrics.recentDeliveries).toHaveLength(0);
+ });
+
+ it("should record successful webhook delivery and compute latency and rate", () => {
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "org.created",
+ status: 200,
+ latencyMs: 45,
+ success: true,
+ summary: "Organization created: Acquired Company",
+ });
+
+ const metrics = webhookMonitor.getMetrics();
+ expect(metrics.totalEvents).toBe(1);
+ expect(metrics.successCount).toBe(1);
+ expect(metrics.failureCount).toBe(0);
+ expect(metrics.successRate).toBe(100);
+ expect(metrics.avgLatencyMs).toBe(45);
+ expect(metrics.status).toBe("healthy");
+
+ expect(metrics.routes["dos-org-sync"].total).toBe(1);
+ expect(metrics.routes["dos-org-sync"].success).toBe(1);
+ expect(metrics.routes["dos-org-sync"].status).toBe("healthy");
+ expect(metrics.recentDeliveries[0].event).toBe("org.created");
+ });
+
+ it("should track failures and transition status to degraded / failing", () => {
+ // 3 successes, 2 failures -> 60% success rate -> failing
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "test.ping",
+ status: 200,
+ latencyMs: 10,
+ success: true,
+ });
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "member_added",
+ status: 200,
+ latencyMs: 20,
+ success: true,
+ });
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "member_removed",
+ status: 200,
+ latencyMs: 30,
+ success: true,
+ });
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "org.created",
+ status: 401,
+ latencyMs: 5,
+ success: false,
+ error: "Invalid signature",
+ });
+ webhookMonitor.recordDelivery({
+ source: "dos-org-sync",
+ event: "org.updated",
+ status: 500,
+ latencyMs: 60,
+ success: false,
+ error: "Database timeout",
+ });
+
+ const metrics = webhookMonitor.getMetrics();
+ expect(metrics.totalEvents).toBe(5);
+ expect(metrics.successCount).toBe(3);
+ expect(metrics.failureCount).toBe(2);
+ expect(metrics.successRate).toBe(60);
+ expect(metrics.status).toBe("failing");
+ expect(metrics.avgLatencyMs).toBe(25); // (10+20+30+5+60)/5 = 25
+ });
+
+ it("should cap recent logs to maximum size", () => {
+ for (let i = 0; i < 120; i++) {
+ webhookMonitor.recordDelivery({
+ source: "stripe",
+ event: "checkout.session.completed",
+ status: 200,
+ latencyMs: 15,
+ success: true,
+ });
+ }
+
+ const metrics = webhookMonitor.getMetrics();
+ expect(metrics.totalEvents).toBe(100);
+ expect(metrics.recentDeliveries).toHaveLength(50); // recentDeliveries returns latest 50
+ });
+});
diff --git a/packages/lib/authRateLimiter.ts b/packages/lib/authRateLimiter.ts
new file mode 100644
index 00000000000..651e8e7b9bd
--- /dev/null
+++ b/packages/lib/authRateLimiter.ts
@@ -0,0 +1,120 @@
+import { createHash } from "node:crypto";
+
+import logger from "./logger";
+
+const log = logger.getSubLogger({ prefix: ["authRateLimiter"] });
+
+export interface AuthRateLimitResult {
+ allowed: boolean;
+ remaining: number;
+ /** Epoch ms when the current window ends and attempts reset */
+ resetAt: number;
+}
+
+export interface AuthRateLimiter {
+ limit: (key: string) => AuthRateLimitResult;
+ reset: () => void;
+}
+
+export interface AuthRateLimiterOptions {
+ windowInMs?: number;
+ maxRequests?: number;
+ /** Injectable clock for tests */
+ now?: () => number;
+ /** Injectable bucket store for tests */
+ store?: Map;
+}
+
+export const AUTH_RATE_LIMIT_DEFAULTS = {
+ // 15 min is long enough to make online brute force impractical for weak secrets
+ windowInMs: 15 * 60 * 1000,
+ maxRequests: 5,
+} as const;
+
+// Buckets are bounded so a flood of unique identities cannot grow memory unboundedly
+const MAX_BUCKETS = 100_000;
+
+export function createAuthRateLimiter(options: AuthRateLimiterOptions = {}): AuthRateLimiter {
+ const { windowInMs, maxRequests, now, store } = {
+ ...AUTH_RATE_LIMIT_DEFAULTS,
+ now: Date.now,
+ store: new Map(),
+ ...options,
+ };
+
+ return {
+ limit(key: string): AuthRateLimitResult {
+ try {
+ return limitInStore({ key, store, windowInMs, maxRequests, now });
+ } catch (error) {
+ // Brute-force protection must fail closed: a broken limiter silently
+ // allowing unlimited attempts is worse than briefly blocking requests
+ log.error("Auth rate limiter errored; failing closed", error);
+ return { allowed: false, remaining: 0, resetAt: now() + windowInMs };
+ }
+ },
+ reset(): void {
+ store.clear();
+ },
+ };
+}
+
+function limitInStore({
+ key,
+ store,
+ windowInMs,
+ maxRequests,
+ now,
+}: {
+ key: string;
+ store: Map;
+ windowInMs: number;
+ maxRequests: number;
+ now: () => number;
+}): AuthRateLimitResult {
+ const nowMs = now();
+ const windowStart = Math.floor(nowMs / windowInMs) * windowInMs;
+ const resetAt = windowStart + windowInMs;
+ const bucket = store.get(key);
+
+ if (!bucket || bucket.resetAt <= nowMs) {
+ store.set(key, { count: 1, resetAt });
+ if (store.size > MAX_BUCKETS) {
+ sweepExpiredBuckets(store, nowMs);
+ }
+ return { allowed: true, remaining: maxRequests - 1, resetAt };
+ }
+
+ bucket.count += 1;
+ return {
+ allowed: bucket.count <= maxRequests,
+ remaining: Math.max(0, maxRequests - bucket.count),
+ resetAt: bucket.resetAt,
+ };
+}
+
+function sweepExpiredBuckets(store: Map, nowMs: number): void {
+ for (const [key, bucket] of store) {
+ if (bucket.resetAt <= nowMs) {
+ store.delete(key);
+ }
+ }
+}
+
+const defaultLimiter = createAuthRateLimiter();
+
+/**
+ * Why: the Unkey-based `rateLimiter()` in rateLimit.ts no-ops without
+ * UNKEY_ROOT_KEY, so sensitive auth endpoints (password, 2FA, email-verify)
+ * would be unlimited in default deployments. This limiter always runs; limits
+ * are per app-server instance, which is accurate enough for per-identity
+ * brute-force protection and needs no external dependency.
+ */
+export function limitAuthRate(key: string, limiter: AuthRateLimiter = defaultLimiter): AuthRateLimitResult {
+ return limiter.limit(key);
+}
+
+/** Truncated SHA-256 keeps raw emails/usernames out of limiter memory */
+export function hashRateLimitIdentifier(identifier: string): string {
+ return createHash("sha256").update(identifier).digest("hex").slice(0, 32);
+}
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index 5401ca25ccf..2109e9a937d 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -35,11 +35,11 @@ export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http
/** @deprecated use `WEBAPP_URL` */
export const BASE_URL = WEBAPP_URL;
export const WEBSITE_URL = ensureProtocol(process.env.NEXT_PUBLIC_WEBSITE_URL) || "https://cal.com";
-export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || "Cal.diy";
-export const SUPPORT_MAIL_ADDRESS = process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "help@cal.com";
-export const COMPANY_NAME = process.env.NEXT_PUBLIC_COMPANY_NAME || "Cal.com, Inc.";
-export const SENDER_ID = process.env.NEXT_PUBLIC_SENDER_ID || "Cal";
-export const SENDER_NAME = process.env.NEXT_PUBLIC_SENDGRID_SENDER_NAME || "Cal.diy";
+export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || "Crove";
+export const SUPPORT_MAIL_ADDRESS = process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "help@crove.com";
+export const COMPANY_NAME = process.env.NEXT_PUBLIC_COMPANY_NAME || "MetaDOS LLC";
+export const SENDER_ID = process.env.NEXT_PUBLIC_SENDER_ID || "Crove";
+export const SENDER_NAME = process.env.NEXT_PUBLIC_SENDGRID_SENDER_NAME || "Crove";
export const EMAIL_FROM_NAME = process.env.EMAIL_FROM_NAME || APP_NAME;
// This is the URL from which all Cal Links and their assets are served.
@@ -137,8 +137,22 @@ export const API_NAME_LENGTH_MAX_LIMIT = 80;
export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5";
export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1";
// Needed for orgs
-export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[];
-export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[];
+const parseCommaSeparated = (str?: string): string[] => {
+ if (!str) return [];
+ try {
+ const parsed = JSON.parse(`[${str}]`);
+ if (Array.isArray(parsed)) return parsed;
+ } catch {
+ // fallback for raw comma-separated strings without quotes
+ }
+ return str
+ .split(",")
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
+ .filter(Boolean);
+};
+
+export const ALLOWED_HOSTNAMES = parseCommaSeparated(process.env.ALLOWED_HOSTNAMES);
+export const RESERVED_SUBDOMAINS = parseCommaSeparated(process.env.RESERVED_SUBDOMAINS);
export const ORGANIZATION_SELF_SERVE_PRICE = parseFloat(
process.env.NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW || "37"
diff --git a/packages/lib/hooks/useRouterQuery.ts b/packages/lib/hooks/useRouterQuery.ts
index a77438af8c0..591e335e88c 100644
--- a/packages/lib/hooks/useRouterQuery.ts
+++ b/packages/lib/hooks/useRouterQuery.ts
@@ -11,8 +11,6 @@ function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]
return result;
}
- // Consider setting atleast ES2015 as target
- // @ts-expect-error
for (const [key, value] of entries) {
if (result.hasOwnProperty(key)) {
let currentValue = result[key];
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 9fd65f5dd47..aabe6d1bbe1 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -36,6 +36,6 @@
"@calcom/tsconfig": "workspace:*",
"@calcom/types": "workspace:*",
"@faker-js/faker": "7.6.0",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/lib/ssrfProtection.test.ts b/packages/lib/ssrfProtection.test.ts
index a11712c2a39..9a3218c1ae9 100644
--- a/packages/lib/ssrfProtection.test.ts
+++ b/packages/lib/ssrfProtection.test.ts
@@ -177,19 +177,71 @@ describe("Self-hosted environment behavior", () => {
afterEach(() => {
vi.doUnmock("@calcom/lib/constants");
+ vi.doUnmock("node:dns/promises");
+ vi.unstubAllEnvs();
+ });
+
+ it("blocks private IPs on self-hosted by default (same as cloud)", async () => {
+ const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection");
+ expect(validateSelfHosted("http://192.168.1.1/webhook")).toEqual({
+ isValid: false,
+ error: "Private IP address",
+ });
+ expect(validateSelfHosted("http://10.0.0.1/webhook").isValid).toBe(false);
+ expect(validateSelfHosted("http://172.16.0.1/webhook").isValid).toBe(false);
+ });
+
+ it("blocks localhost on self-hosted by default", async () => {
+ const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection");
+ expect(validateSelfHosted("http://localhost:3000/webhook").isValid).toBe(false);
+ expect(validateSelfHosted("http://127.0.0.1:3000/webhook").isValid).toBe(false);
+ });
+
+ it("still allows public HTTP URLs on self-hosted", async () => {
+ const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection");
+ expect(validateSelfHosted("http://example.com/webhook").isValid).toBe(true);
+ expect(validateSelfHosted("https://internal.example.com/webhook").isValid).toBe(true);
});
- it("allows private IPs for self-hosted (internal webhooks)", async () => {
+ it("allows private targets when SSRF_ALLOW_PRIVATE_IPS=true", async () => {
+ vi.stubEnv("SSRF_ALLOW_PRIVATE_IPS", "true");
const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection");
+ expect(validateSelfHosted("http://127.0.0.1/webhook").isValid).toBe(true);
expect(validateSelfHosted("http://192.168.1.1/webhook").isValid).toBe(true);
- expect(validateSelfHosted("http://10.0.0.1/webhook").isValid).toBe(true);
- expect(validateSelfHosted("http://172.16.0.1/webhook").isValid).toBe(true);
+ expect(validateSelfHosted("http://internal-service.local/webhook").isValid).toBe(true);
+ });
+
+ it("still blocks cloud metadata endpoints when SSRF_ALLOW_PRIVATE_IPS=true", async () => {
+ vi.stubEnv("SSRF_ALLOW_PRIVATE_IPS", "true");
+ const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection");
+ expect(validateSelfHosted("http://169.254.169.254/latest/meta-data/").isValid).toBe(false);
+ expect(validateSelfHosted("http://metadata.google.internal/computeMetadata/v1/").isValid).toBe(false);
+ });
+
+ it("skips the DNS private-IP check when SSRF_ALLOW_PRIVATE_IPS=true", async () => {
+ vi.doMock("node:dns/promises", () => ({
+ default: {
+ lookup: vi.fn().mockResolvedValue([{ address: "10.0.0.5", family: 4 }]),
+ },
+ }));
+ vi.stubEnv("SSRF_ALLOW_PRIVATE_IPS", "true");
+ const { validateUrlForSSRF: validateSelfHosted } = await import("./ssrfProtection");
+ expect((await validateSelfHosted("http://internal-service.local/webhook")).isValid).toBe(true);
+ });
+
+ it("rejects hostnames that resolve to private IPs on self-hosted (DNS rebinding protection)", async () => {
+ vi.doMock("node:dns/promises", () => ({
+ default: {
+ lookup: vi.fn().mockResolvedValue([{ address: "10.0.0.5", family: 4 }]),
+ },
+ }));
+ const { validateUrlForSSRF: validateSelfHosted } = await import("./ssrfProtection");
+ expect((await validateSelfHosted("http://internal-service.local/webhook")).isValid).toBe(false);
});
it("allows HTTP URLs for self-hosted", async () => {
const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection");
- expect(validateSelfHosted("http://internal-service.local/webhook").isValid).toBe(true);
- expect(validateSelfHosted("http://localhost:3000/webhook").isValid).toBe(true);
+ expect(validateSelfHosted("http://public-webhook.example.com/webhook").isValid).toBe(true);
});
it("still blocks cloud metadata endpoints even on self-hosted", async () => {
diff --git a/packages/lib/ssrfProtection.ts b/packages/lib/ssrfProtection.ts
index 50051c2ea20..2c47c40217e 100644
--- a/packages/lib/ssrfProtection.ts
+++ b/packages/lib/ssrfProtection.ts
@@ -90,6 +90,17 @@ export function isBlockedHostname(hostname: string): boolean {
return BLOCKED_HOSTNAMES.includes(normalized);
}
+/**
+ * Self-hosted operators may set SSRF_ALLOW_PRIVATE_IPS="true" to allow URLs pointing
+ * at internal services (private/loopback IPs and hostnames that resolve to them).
+ * Only the private-IP and DNS-resolution checks are skipped; cloud metadata endpoints
+ * stay blocked unconditionally. Ignored on Cal.diy cloud, where private-IP protection
+ * is never disabled.
+ */
+function isPrivateTargetOptOut(): boolean {
+ return IS_SELF_HOSTED && process.env.SSRF_ALLOW_PRIVATE_IPS === "true";
+}
+
// Check if hostname is a cloud metadata endpoint (blocked even on self-hosted)
function isCloudMetadataEndpoint(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
@@ -139,13 +150,31 @@ function validateUrlCore(urlString: string): SSRFValidationResult | { url: URL }
return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME };
}
- // Self-hosted: allow HTTP and private IPs (for internal webhooks)
- // Still restrict to HTTP/HTTPS protocols only (no file://, ftp://, etc.)
+ // Self-hosted: HTTP is allowed, but private-network targets are blocked exactly
+ // like on cloud unless SSRF_ALLOW_PRIVATE_IPS=true opts out. Why: previously this
+ // branch returned early after only a protocol check, so on self-hosted any
+ // authenticated user could reach internal services (127.0.0.1, 169.254.169.254,
+ // internal hostnames) through user-supplied URLs such as viewer.webhook.testTrigger.
+ // Metadata endpoints stay blocked unconditionally above (self-hosted boxes may
+ // still run on AWS/GCP/Azure).
if (IS_SELF_HOSTED) {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { isValid: false, error: ERRORS.INVALID_PROTOCOL };
}
- return { isValid: true };
+
+ if (!isPrivateTargetOptOut()) {
+ if (isBlockedHostname(url.hostname)) {
+ return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME };
+ }
+
+ // Check if hostname is an IP address and if it's private
+ const hostnameForIPCheck = stripIPv6Brackets(url.hostname);
+ if (ipaddr.isValid(hostnameForIPCheck) && isPrivateIP(hostnameForIPCheck)) {
+ return { isValid: false, error: ERRORS.PRIVATE_IP };
+ }
+ }
+
+ return { url };
}
if (url.protocol !== "https:") {
@@ -176,16 +205,20 @@ export async function validateUrlForSSRF(urlString: string): Promise parseInt(expires, 10)) {
return { valid: false };
@@ -27,7 +42,9 @@ export function verifyVideoToken(token: string): {
const payload = `${recordingId}:${expires}`;
const expectedHmac = createHmac("sha256", secret).update(payload).digest("hex");
- if (receivedHmac !== expectedHmac) {
+ // Why: `!==` on the hex digest short-circuits on the first differing byte,
+ // leaking timing information; compare in constant time instead.
+ if (typeof receivedHmac !== "string" || !timingSafeStringsEqual(receivedHmac, expectedHmac)) {
return { valid: false };
}
diff --git a/packages/lib/webhook-signature.ts b/packages/lib/webhook-signature.ts
new file mode 100644
index 00000000000..9e8f05cd82e
--- /dev/null
+++ b/packages/lib/webhook-signature.ts
@@ -0,0 +1,45 @@
+import { createHash, createHmac, timingSafeEqual } from "node:crypto";
+
+const SHA256_HEX_64_PATTERN = /^[0-9a-fA-F]{64}$/;
+
+/**
+ * Verify an HMAC-SHA256 webhook signature over the raw request body.
+ * Expected header format: `sha256=` (64 hex characters).
+ */
+export function verifyWebhookSignature(
+ rawBody: string,
+ signatureHeader: string | null,
+ secret: string
+): boolean {
+ if (!rawBody || !signatureHeader || !secret) {
+ return false;
+ }
+
+ if (!signatureHeader.startsWith("sha256=")) {
+ return false;
+ }
+
+ const providedSignature = signatureHeader.slice("sha256=".length);
+ if (!SHA256_HEX_64_PATTERN.test(providedSignature)) {
+ return false;
+ }
+
+ const expectedSignature = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
+
+ // timingSafeEqual throws when buffer lengths differ, so validate the hex length above before comparing
+ return timingSafeEqual(Buffer.from(providedSignature, "hex"), Buffer.from(expectedSignature, "hex"));
+}
+
+/**
+ * Constant-time comparison of two arbitrary strings (secrets or HMAC digests).
+ * Both sides are hashed with SHA-256 first so the buffers always have equal length,
+ * which timingSafeEqual requires.
+ *
+ * Why: `===` short-circuits at the first differing byte, leaking timing information
+ * that helps an attacker forge signatures or secrets byte by byte.
+ */
+export function timingSafeStringsEqual(a: string, b: string): boolean {
+ const digestA = createHash("sha256").update(a, "utf8").digest();
+ const digestB = createHash("sha256").update(b, "utf8").digest();
+ return timingSafeEqual(digestA, digestB);
+}
diff --git a/packages/lib/webhookMonitor.ts b/packages/lib/webhookMonitor.ts
new file mode 100644
index 00000000000..530595011f5
--- /dev/null
+++ b/packages/lib/webhookMonitor.ts
@@ -0,0 +1,171 @@
+export interface WebhookDeliveryLog {
+ id: string;
+ source: "dos-org-sync" | "brevo" | "calendar-subscription" | "stripe" | "user-webhook" | string;
+ event: string;
+ status: number;
+ latencyMs: number;
+ timestamp: string;
+ success: boolean;
+ error?: string;
+ ip?: string;
+ summary?: string;
+}
+
+export interface RouteMetric {
+ name: string;
+ source: string;
+ total: number;
+ success: number;
+ failure: number;
+ avgLatencyMs: number;
+ lastEventAt: string | null;
+ status: "healthy" | "degraded" | "failing" | "idle";
+}
+
+export interface WebhookMonitoringSummary {
+ status: "healthy" | "degraded" | "failing" | "idle";
+ totalEvents: number;
+ successCount: number;
+ failureCount: number;
+ successRate: number;
+ avgLatencyMs: number;
+ activeListeners: number;
+ uptimeSeconds: number;
+ routes: Record;
+ recentDeliveries: WebhookDeliveryLog[];
+}
+
+const MAX_LOG_SIZE = 100;
+
+class WebhookMonitorService {
+ private logs: WebhookDeliveryLog[] = [];
+ private startTime = Date.now();
+
+ private routeConfig: Record = {
+ "dos-org-sync": { name: "DOS.Me Org & Identity Sync" },
+ "crove-crm": { name: "Crove CRM Direct Sync" },
+ brevo: { name: "Brevo CRM Event Bridge" },
+ "calendar-subscription": { name: "Google & Microsoft Calendar Sync" },
+ stripe: { name: "Stripe Payment & Subscriptions" },
+ "user-webhook": { name: "Outgoing User Webhooks" },
+ };
+
+ /**
+ * Record a webhook processing event
+ */
+ public recordDelivery(
+ entry: Omit & { id?: string; timestamp?: string }
+ ): WebhookDeliveryLog {
+ const logItem: WebhookDeliveryLog = {
+ id: entry.id || `wh_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
+ source: entry.source,
+ event: entry.event,
+ status: entry.status,
+ latencyMs: Math.max(0, Math.round(entry.latencyMs)),
+ timestamp: entry.timestamp || new Date().toISOString(),
+ success: entry.success ?? (entry.status >= 200 && entry.status < 300),
+ error: entry.error,
+ ip: entry.ip,
+ summary: entry.summary,
+ };
+
+ this.logs.unshift(logItem);
+ if (this.logs.length > MAX_LOG_SIZE) {
+ this.logs = this.logs.slice(0, MAX_LOG_SIZE);
+ }
+
+ return logItem;
+ }
+
+ /**
+ * Calculate aggregated metrics and health status
+ */
+ public getMetrics(): WebhookMonitoringSummary {
+ const totalEvents = this.logs.length;
+ const successCount = this.logs.filter((l) => l.success).length;
+ const failureCount = totalEvents - successCount;
+ const successRate = totalEvents > 0 ? Math.round((successCount / totalEvents) * 1000) / 10 : 100;
+ const totalLatency = this.logs.reduce((acc, l) => acc + l.latencyMs, 0);
+ const avgLatencyMs = totalEvents > 0 ? Math.round(totalLatency / totalEvents) : 0;
+
+ const routes: Record = {};
+
+ for (const [sourceKey, cfg] of Object.entries(this.routeConfig)) {
+ const routeLogs = this.logs.filter((l) => l.source === sourceKey);
+ const routeTotal = routeLogs.length;
+ const routeSuccess = routeLogs.filter((l) => l.success).length;
+ const routeFailure = routeTotal - routeSuccess;
+ const routeLatency = routeLogs.reduce((acc, l) => acc + l.latencyMs, 0);
+ const routeAvgLatency = routeTotal > 0 ? Math.round(routeLatency / routeTotal) : 0;
+ const lastEventAt = routeLogs.length > 0 ? routeLogs[0].timestamp : null;
+
+ let status: RouteMetric["status"] = "idle";
+ if (routeTotal > 0) {
+ const routeSuccessRate = (routeSuccess / routeTotal) * 100;
+ if (routeSuccessRate >= 95) {
+ status = "healthy";
+ } else if (routeSuccessRate >= 80) {
+ status = "degraded";
+ } else {
+ status = "failing";
+ }
+ }
+
+ routes[sourceKey] = {
+ name: cfg.name,
+ source: sourceKey,
+ total: routeTotal,
+ success: routeSuccess,
+ failure: routeFailure,
+ avgLatencyMs: routeAvgLatency,
+ lastEventAt,
+ status,
+ };
+ }
+
+ let overallStatus: WebhookMonitoringSummary["status"] = "idle";
+ if (totalEvents > 0) {
+ if (successRate >= 95) {
+ overallStatus = "healthy";
+ } else if (successRate >= 80) {
+ overallStatus = "degraded";
+ } else {
+ overallStatus = "failing";
+ }
+ }
+
+ return {
+ status: overallStatus,
+ totalEvents,
+ successCount,
+ failureCount,
+ successRate,
+ avgLatencyMs,
+ activeListeners: Object.keys(this.routeConfig).length,
+ uptimeSeconds: Math.floor((Date.now() - this.startTime) / 1000),
+ routes,
+ recentDeliveries: this.logs.slice(0, 50),
+ };
+ }
+
+ /**
+ * Reset logs (for testing or maintenance)
+ */
+ public reset(): void {
+ this.logs = [];
+ this.startTime = Date.now();
+ }
+}
+
+// Ensure singleton instance in global scope across Next.js hot-reloads
+const globalForWebhookMonitor = globalThis as unknown as {
+ __croveWebhookMonitor?: WebhookMonitorService;
+};
+
+export const webhookMonitor = globalForWebhookMonitor.__croveWebhookMonitor || new WebhookMonitorService();
+
+// Assignment must be unconditional: in production each route module otherwise gets its own
+// instance, so the dashboard under-reports deliveries (MD-25).
+globalForWebhookMonitor.__croveWebhookMonitor = webhookMonitor;
+
+export default webhookMonitor;
diff --git a/packages/mcp-server/bin/crove-cal-mcp.ts b/packages/mcp-server/bin/crove-cal-mcp.ts
new file mode 100644
index 00000000000..49fda0fd890
--- /dev/null
+++ b/packages/mcp-server/bin/crove-cal-mcp.ts
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+import { startStdioServer } from "../src/index";
+
+startStdioServer().catch((error) => {
+ console.error("[crove-cal-mcp] Server error:", error);
+ process.exit(1);
+});
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
new file mode 100644
index 00000000000..6ac55e9d7ff
--- /dev/null
+++ b/packages/mcp-server/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@calcom/mcp-server",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Model Context Protocol (MCP) Server for Crove Cal & Crove OS Agents",
+ "main": "src/index.ts",
+ "bin": {
+ "crove-cal-mcp": "./bin/crove-cal-mcp.ts"
+ },
+ "scripts": {
+ "start": "ts-node --transpile-only src/index.ts",
+ "type-check": "tsc --pretty --noEmit",
+ "lint": "biome lint .",
+ "lint:fix": "biome lint --write ."
+ },
+ "dependencies": {
+ "@calcom/prisma": "workspace:*",
+ "@modelcontextprotocol/sdk": "1.26.0",
+ "zod": "3.25.76"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.3.10",
+ "@calcom/tsconfig": "workspace:*",
+ "@types/node": "20.17.24",
+ "ts-node": "^10.9.2",
+ "typescript": "6.0.3",
+ "vitest": "^4.1.8"
+ }
+}
diff --git a/packages/mcp-server/src/__tests__/mcp-server.test.ts b/packages/mcp-server/src/__tests__/mcp-server.test.ts
new file mode 100644
index 00000000000..128ccb58a28
--- /dev/null
+++ b/packages/mcp-server/src/__tests__/mcp-server.test.ts
@@ -0,0 +1,639 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import {
+ cancelBookingHandler,
+ createBookingHandler,
+ getBookingHandler,
+ listBookingsHandler,
+ rescheduleBookingHandler,
+} from "../tools/bookings";
+import {
+ createEventTypeHandler,
+ deleteEventTypeHandler,
+ getEventTypeDetailsHandler,
+ listEventTypesHandler,
+ updateEventTypeHandler,
+} from "../tools/eventTypes";
+import { getAvailableSlotsHandler } from "../tools/slots";
+import { getUserProfileHandler, listSchedulesHandler } from "../tools/users";
+import { createCroveCalMcpServer } from "../server";
+
+const mockPrisma = {
+ eventType: {
+ findMany: vi.fn(),
+ findFirst: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ booking: {
+ findMany: vi.fn(),
+ findFirst: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ user: {
+ findFirst: vi.fn(),
+ },
+ schedule: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ },
+};
+
+const HOST_USER_ID = 10;
+
+describe("Crove Cal MCP Tools", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Event Types", () => {
+ test("listEventTypesHandler should query event types scoped to the host user", async () => {
+ mockPrisma.eventType.findMany.mockResolvedValue([
+ { id: 1, title: "Quick 15", slug: "15min", length: 15 },
+ { id: 2, title: "Deep Dive 45", slug: "45min", length: 45 },
+ ]);
+
+ const result = await listEventTypesHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ username: "joy",
+ });
+ expect(result).toHaveLength(2);
+ expect(mockPrisma.eventType.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ hidden: false,
+ userId: HOST_USER_ID,
+ users: { some: { username: "joy" } },
+ }),
+ })
+ );
+ });
+
+ test("getEventTypeDetailsHandler should return details by ID scoped to the host user", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ title: "Quick 15",
+ slug: "15min",
+ length: 15,
+ });
+
+ const result = await getEventTypeDetailsHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ });
+ expect(result.id).toBe(1);
+ expect(result.slug).toBe("15min");
+ expect(mockPrisma.eventType.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ id: 1,
+ userId: HOST_USER_ID,
+ }),
+ })
+ );
+ });
+
+ test("createEventTypeHandler should create a new event type for the host user", async () => {
+ mockPrisma.user.findFirst.mockResolvedValue({ id: HOST_USER_ID });
+ mockPrisma.eventType.create.mockResolvedValue({
+ id: 3,
+ title: "Discovery Call",
+ slug: "discovery-call",
+ length: 30,
+ userId: HOST_USER_ID,
+ });
+
+ const result = await createEventTypeHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ title: "Discovery Call",
+ slug: "discovery-call",
+ length: 30,
+ username: "joy",
+ });
+
+ expect(result.id).toBe(3);
+ expect(mockPrisma.eventType.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ title: "Discovery Call",
+ slug: "discovery-call",
+ length: 30,
+ userId: HOST_USER_ID,
+ }),
+ })
+ );
+ });
+
+ test("updateEventTypeHandler should update an existing event type scoped to the host user", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({ id: 3 });
+ mockPrisma.eventType.update.mockResolvedValue({
+ id: 3,
+ title: "Updated Discovery Call",
+ length: 45,
+ });
+
+ const result = await updateEventTypeHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ id: 3,
+ title: "Updated Discovery Call",
+ length: 45,
+ });
+
+ expect(result.title).toBe("Updated Discovery Call");
+ expect(mockPrisma.eventType.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 3, userId: HOST_USER_ID },
+ data: expect.objectContaining({
+ title: "Updated Discovery Call",
+ length: 45,
+ }),
+ })
+ );
+ });
+
+ test("updateEventTypeHandler should reject an event type owned by another user", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue(null);
+
+ await expect(
+ updateEventTypeHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ id: 3,
+ title: "Hijacked",
+ })
+ ).rejects.toThrow("Event type with ID 3 not found for user 10");
+ expect(mockPrisma.eventType.update).not.toHaveBeenCalled();
+ });
+
+ test("deleteEventTypeHandler should delete an event type scoped to the host user", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({ id: 3 });
+ mockPrisma.eventType.delete.mockResolvedValue({
+ id: 3,
+ title: "Discovery Call",
+ slug: "discovery-call",
+ });
+
+ const result = await deleteEventTypeHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ id: 3,
+ });
+ expect(result.id).toBe(3);
+ expect(mockPrisma.eventType.delete).toHaveBeenCalledWith({
+ where: { id: 3, userId: HOST_USER_ID },
+ select: expect.any(Object),
+ });
+ });
+
+ test("deleteEventTypeHandler should reject an event type owned by another user", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue(null);
+
+ await expect(
+ deleteEventTypeHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ id: 3,
+ })
+ ).rejects.toThrow("Event type with ID 3 not found for user 10");
+ expect(mockPrisma.eventType.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Available Slots", () => {
+ test("getAvailableSlotsHandler should fall back to 09:00-17:00 UTC when host has no schedule", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ length: 30,
+ timeZone: "UTC",
+ userId: HOST_USER_ID,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null },
+ });
+ mockPrisma.schedule.findFirst.mockResolvedValue(null);
+
+ // 1 existing booking at 2026-09-01T10:00:00.000Z to 10:30:00.000Z
+ mockPrisma.booking.findMany.mockResolvedValue([
+ {
+ startTime: new Date("2026-09-01T10:00:00.000Z"),
+ endTime: new Date("2026-09-01T10:30:00.000Z"),
+ },
+ ]);
+
+ const result = await getAvailableSlotsHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ dateFrom: "2026-09-01",
+ dateTo: "2026-09-01",
+ });
+
+ expect(result.eventTypeId).toBe(1);
+ expect(result.length).toBe(30);
+ expect(result.slots.length).toBeGreaterThan(0);
+
+ // Verify that 10:00:00.000Z is not in the available slots
+ const hasOverlapSlot = result.slots.some((s) => s.time === "2026-09-01T10:00:00.000Z");
+ expect(hasOverlapSlot).toBe(false);
+
+ // Verify that 09:00:00.000Z and 10:30:00.000Z are present
+ const has9amSlot = result.slots.some((s) => s.time === "2026-09-01T09:00:00.000Z");
+ const has1030Slot = result.slots.some((s) => s.time === "2026-09-01T10:30:00.000Z");
+ expect(has9amSlot).toBe(true);
+ expect(has1030Slot).toBe(true);
+ });
+
+ test("getAvailableSlotsHandler should use the host schedule's UTC working windows", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ length: 30,
+ timeZone: "UTC",
+ userId: HOST_USER_ID,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null },
+ });
+ // Tuesday 2026-09-01, working window 10:00-12:00 UTC (days: 2 = Tuesday)
+ mockPrisma.schedule.findFirst.mockResolvedValue({
+ timeZone: "UTC",
+ availability: [
+ {
+ days: [2],
+ startTime: new Date("1970-01-01T10:00:00.000Z"),
+ endTime: new Date("1970-01-01T12:00:00.000Z"),
+ },
+ ],
+ });
+ mockPrisma.booking.findMany.mockResolvedValue([]);
+
+ const result = await getAvailableSlotsHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ dateFrom: "2026-09-01",
+ dateTo: "2026-09-01",
+ });
+
+ // 30-minute slots inside 10:00-12:00: 10:00, 10:30, 11:00, 11:30
+ expect(result.slots).toHaveLength(4);
+ expect(result.slots[0].time).toBe("2026-09-01T10:00:00.000Z");
+ expect(result.slots[3].time).toBe("2026-09-01T11:30:00.000Z");
+ const has9amSlot = result.slots.some((s) => s.time === "2026-09-01T09:00:00.000Z");
+ expect(has9amSlot).toBe(false);
+ });
+
+ test("getAvailableSlotsHandler should fall back to fixed UTC hours for non-UTC schedules", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ length: 30,
+ timeZone: "UTC",
+ userId: HOST_USER_ID,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null },
+ });
+ mockPrisma.schedule.findFirst.mockResolvedValue({
+ timeZone: "Asia/Ho_Chi_Minh",
+ availability: [
+ {
+ days: [2],
+ startTime: new Date("1970-01-01T10:00:00.000Z"),
+ endTime: new Date("1970-01-01T12:00:00.000Z"),
+ },
+ ],
+ });
+ mockPrisma.booking.findMany.mockResolvedValue([]);
+
+ const result = await getAvailableSlotsHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ dateFrom: "2026-09-01",
+ dateTo: "2026-09-01",
+ });
+
+ const has9amSlot = result.slots.some((s) => s.time === "2026-09-01T09:00:00.000Z");
+ expect(has9amSlot).toBe(true);
+ });
+
+ test("getAvailableSlotsHandler should reject date spans longer than 60 days", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ length: 30,
+ timeZone: "UTC",
+ userId: HOST_USER_ID,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null },
+ });
+
+ await expect(
+ getAvailableSlotsHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ dateFrom: "2026-09-01",
+ dateTo: "2026-11-05",
+ })
+ ).rejects.toThrow("Date range too large");
+ });
+ });
+
+ describe("Bookings Management", () => {
+ test("createBookingHandler should create a booking with attendee", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ title: "Intro Call",
+ length: 30,
+ userId: HOST_USER_ID,
+ requiresConfirmation: false,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", name: "Host Name" },
+ });
+ mockPrisma.booking.findFirst.mockResolvedValue(null);
+
+ mockPrisma.booking.create.mockImplementation(({ data }) => ({
+ id: 50,
+ uid: data.uid,
+ title: data.title,
+ startTime: data.startTime,
+ endTime: data.endTime,
+ status: data.status,
+ }));
+
+ const result = await createBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ start: "2026-09-01T14:00:00.000Z",
+ name: "Alice Client",
+ email: "alice@example.com",
+ notes: "Discuss integration",
+ });
+
+ expect(result.id).toBe(50);
+ expect(result.status).toBe("ACCEPTED");
+ expect(mockPrisma.booking.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ eventTypeId: 1,
+ userPrimaryEmail: "host@crove.com",
+ attendees: {
+ create: expect.objectContaining({
+ name: "Alice Client",
+ email: "alice@example.com",
+ }),
+ },
+ }),
+ })
+ );
+ });
+
+ test("createBookingHandler should reject a conflicting time slot", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ title: "Intro Call",
+ length: 30,
+ userId: HOST_USER_ID,
+ requiresConfirmation: false,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", name: "Host Name" },
+ });
+ mockPrisma.booking.findFirst.mockResolvedValue({
+ id: 99,
+ startTime: new Date("2026-09-01T14:00:00.000Z"),
+ endTime: new Date("2026-09-01T14:30:00.000Z"),
+ });
+
+ await expect(
+ createBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ start: "2026-09-01T14:00:00.000Z",
+ name: "Alice Client",
+ email: "alice@example.com",
+ })
+ ).rejects.toThrow("Time slot already booked");
+ expect(mockPrisma.booking.create).not.toHaveBeenCalled();
+ });
+
+ test("createBookingHandler should mark bookings PENDING when the event type requires confirmation", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue({
+ id: 1,
+ title: "Intro Call",
+ length: 30,
+ userId: HOST_USER_ID,
+ requiresConfirmation: true,
+ owner: { id: HOST_USER_ID, email: "host@crove.com", name: "Host Name" },
+ });
+ mockPrisma.booking.findFirst.mockResolvedValue(null);
+
+ mockPrisma.booking.create.mockImplementation(({ data }) => ({
+ id: 51,
+ uid: data.uid,
+ title: data.title,
+ startTime: data.startTime,
+ endTime: data.endTime,
+ status: data.status,
+ }));
+
+ const result = await createBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ start: "2026-09-01T16:00:00.000Z",
+ name: "Bob Client",
+ email: "bob@example.com",
+ });
+
+ expect(result.status).toBe("PENDING");
+ expect(mockPrisma.booking.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ status: "PENDING",
+ }),
+ })
+ );
+ });
+
+ test("createBookingHandler should reject event types owned by another user", async () => {
+ mockPrisma.eventType.findFirst.mockResolvedValue(null);
+
+ await expect(
+ createBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ eventTypeId: 1,
+ start: "2026-09-01T14:00:00.000Z",
+ name: "Alice Client",
+ email: "alice@example.com",
+ })
+ ).rejects.toThrow("Event type with ID 1 not found for user 10");
+ expect(mockPrisma.booking.create).not.toHaveBeenCalled();
+ });
+
+ test("getBookingHandler should return booking by UID scoped to the host user", async () => {
+ mockPrisma.booking.findFirst.mockResolvedValue({
+ id: 50,
+ uid: "booking-uid-123",
+ title: "Meeting",
+ status: "ACCEPTED",
+ });
+
+ const result = await getBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ bookingUid: "booking-uid-123",
+ });
+ expect(result.uid).toBe("booking-uid-123");
+ expect(mockPrisma.booking.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ uid: "booking-uid-123",
+ userId: HOST_USER_ID,
+ }),
+ })
+ );
+ });
+
+ test("rescheduleBookingHandler should update booking times and store the original UID in fromReschedule", async () => {
+ mockPrisma.booking.findFirst.mockResolvedValue({
+ id: 50,
+ uid: "booking-uid-123",
+ userId: HOST_USER_ID,
+ startTime: new Date("2026-09-01T14:00:00.000Z"),
+ endTime: new Date("2026-09-01T14:30:00.000Z"),
+ eventType: { length: 30 },
+ });
+
+ mockPrisma.booking.update.mockResolvedValue({
+ id: 50,
+ uid: "booking-uid-123",
+ startTime: new Date("2026-09-02T15:00:00.000Z"),
+ endTime: new Date("2026-09-02T15:30:00.000Z"),
+ rescheduled: true,
+ fromReschedule: "booking-uid-123",
+ });
+
+ const result = await rescheduleBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ bookingUid: "booking-uid-123",
+ newStart: "2026-09-02T15:00:00.000Z",
+ reason: "Client had a conflict",
+ });
+
+ expect(result.rescheduled).toBe(true);
+ expect(mockPrisma.booking.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 50 },
+ data: expect.objectContaining({
+ rescheduled: true,
+ fromReschedule: "booking-uid-123",
+ }),
+ })
+ );
+ });
+
+ test("rescheduleBookingHandler should reject bookings hosted by another user", async () => {
+ mockPrisma.booking.findFirst.mockResolvedValue(null);
+
+ await expect(
+ rescheduleBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ bookingUid: "booking-uid-123",
+ newStart: "2026-09-02T15:00:00.000Z",
+ })
+ ).rejects.toThrow("Booking with UID booking-uid-123 not found for user 10");
+ expect(mockPrisma.booking.update).not.toHaveBeenCalled();
+ });
+
+ test("cancelBookingHandler should update booking status to CANCELLED", async () => {
+ mockPrisma.booking.findFirst.mockResolvedValue({
+ id: 50,
+ userId: HOST_USER_ID,
+ status: "ACCEPTED",
+ });
+ mockPrisma.booking.update.mockResolvedValue({
+ id: 50,
+ uid: "booking-uid-123",
+ status: "CANCELLED",
+ cancellationReason: "Schedule conflict",
+ });
+
+ const result = await cancelBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ bookingUid: "booking-uid-123",
+ cancellationReason: "Schedule conflict",
+ });
+
+ expect(result.status).toBe("CANCELLED");
+ expect(mockPrisma.booking.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 50 },
+ data: expect.objectContaining({
+ status: "CANCELLED",
+ cancellationReason: "Schedule conflict",
+ }),
+ })
+ );
+ });
+
+ test("cancelBookingHandler should reject bookings hosted by another user", async () => {
+ mockPrisma.booking.findFirst.mockResolvedValue(null);
+
+ await expect(
+ cancelBookingHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ bookingUid: "booking-uid-123",
+ })
+ ).rejects.toThrow("Booking with UID booking-uid-123 not found for user 10");
+ expect(mockPrisma.booking.update).not.toHaveBeenCalled();
+ });
+
+ test("listBookingsHandler should list host bookings scoped to the host user", async () => {
+ mockPrisma.booking.findMany.mockResolvedValue([
+ { id: 1, uid: "b1", title: "Meeting 1", status: "ACCEPTED" },
+ { id: 2, uid: "b2", title: "Meeting 2", status: "ACCEPTED" },
+ ]);
+
+ const result = await listBookingsHandler(mockPrisma as any, {
+ userId: HOST_USER_ID,
+ userEmail: "joy@dos.ai",
+ status: "ACCEPTED",
+ });
+
+ expect(result).toHaveLength(2);
+ expect(mockPrisma.booking.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: HOST_USER_ID,
+ status: "ACCEPTED",
+ }),
+ })
+ );
+ });
+ });
+
+ describe("Users & Schedules Management", () => {
+ test("getUserProfileHandler should return user profile and organizations", async () => {
+ mockPrisma.user.findFirst.mockResolvedValue({
+ id: HOST_USER_ID,
+ username: "joy",
+ email: "joy@dos.ai",
+ name: "JOY",
+ timeZone: "Asia/Ho_Chi_Minh",
+ teams: [{ role: "OWNER", accepted: true, team: { id: 1, name: "JOY", isOrganization: true } }],
+ });
+
+ const result = await getUserProfileHandler(mockPrisma as any, { email: "joy@dos.ai" });
+ expect(result.id).toBe(HOST_USER_ID);
+ expect(result.username).toBe("joy");
+ expect(result.teams).toHaveLength(1);
+ });
+
+ test("listSchedulesHandler should return schedules with availability intervals", async () => {
+ mockPrisma.user.findFirst.mockResolvedValue({ id: HOST_USER_ID });
+ mockPrisma.schedule.findMany.mockResolvedValue([
+ {
+ id: 1,
+ name: "Working Hours",
+ timeZone: "Asia/Ho_Chi_Minh",
+ availability: [{ id: 1, days: [1, 2, 3, 4, 5], startTime: new Date(), endTime: new Date() }],
+ },
+ ]);
+
+ const result = await listSchedulesHandler(mockPrisma as any, { username: "joy" });
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("Working Hours");
+ });
+ });
+
+ describe("MCP Server Initialization", () => {
+ test("createCroveCalMcpServer should initialize and register all 13 tools", () => {
+ const server = createCroveCalMcpServer(mockPrisma as any);
+ expect(server).toBeDefined();
+ });
+ });
+});
diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts
new file mode 100644
index 00000000000..804320d4ae3
--- /dev/null
+++ b/packages/mcp-server/src/index.ts
@@ -0,0 +1,22 @@
+import prisma from "@calcom/prisma";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createCroveCalMcpServer } from "./server";
+
+export { createCroveCalMcpServer } from "./server";
+export * from "./tools/bookings";
+export * from "./tools/eventTypes";
+export * from "./tools/slots";
+
+export async function startStdioServer() {
+ const mcpServer = createCroveCalMcpServer(prisma);
+ const transport = new StdioServerTransport();
+ await mcpServer.connect(transport);
+ console.error("[crove-cal-mcp] Crove Cal MCP Server running on stdio transport.");
+}
+
+if (require.main === module) {
+ startStdioServer().catch((error) => {
+ console.error("[crove-cal-mcp] Fatal error starting MCP server:", error);
+ process.exit(1);
+ });
+}
diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts
new file mode 100644
index 00000000000..42ed53c1db0
--- /dev/null
+++ b/packages/mcp-server/src/server.ts
@@ -0,0 +1,473 @@
+import type { PrismaClient } from "@calcom/prisma";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import {
+ cancelBookingHandler,
+ createBookingHandler,
+ getBookingHandler,
+ listBookingsHandler,
+ rescheduleBookingHandler,
+} from "./tools/bookings";
+import {
+ createEventTypeHandler,
+ deleteEventTypeHandler,
+ getEventTypeDetailsHandler,
+ listEventTypesHandler,
+ updateEventTypeHandler,
+} from "./tools/eventTypes";
+import { getAvailableSlotsHandler } from "./tools/slots";
+import { getUserProfileHandler, listSchedulesHandler } from "./tools/users";
+
+export function createCroveCalMcpServer(prisma: PrismaClient) {
+ const server = new McpServer({
+ name: "crove-cal-mcp",
+ version: "2.0.0",
+ });
+
+ // Tool 1: list_event_types
+ server.registerTool(
+ "crove_cal_list_event_types",
+ {
+ title: "List Event Types",
+ description: "List available meeting and booking event types for a user or organization in Crove Cal.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope). All returned event types belong to this user"),
+ username: z.string().optional().describe("Username of the host (e.g., 'joy')"),
+ orgSlug: z.string().optional().describe("Organization slug (e.g., 'crove')"),
+ limit: z.number().optional().describe("Maximum number of event types to return (default 50)"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await listEventTypesHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error listing event types: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 2: get_event_type
+ server.registerTool(
+ "crove_cal_get_event_type",
+ {
+ title: "Get Event Type Details",
+ description: "Get detailed information about a specific event type by ID or slug.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — required to read event types by ID"),
+ eventTypeId: z.number().optional().describe("Event Type ID"),
+ slug: z.string().optional().describe("Event Type Slug (e.g., '30min')"),
+ username: z.string().optional().describe("Username of the host if slug is provided"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await getEventTypeDetailsHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error fetching event type: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 3: create_event_type
+ server.registerTool(
+ "crove_cal_create_event_type",
+ {
+ title: "Create Event Type",
+ description: "Create a new meeting/booking event type (e.g., 15min Discovery, 45min Demo).",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — the new event type is created for this user"),
+ title: z.string().describe("Title of the event type (e.g., 'Discovery Call')"),
+ slug: z.string().describe("Unique URL slug (e.g., 'discovery-call')"),
+ length: z.number().describe("Duration of the meeting in minutes (e.g., 30)"),
+ description: z.string().optional().describe("Description shown to attendees"),
+ username: z.string().optional().describe("Username of the host"),
+ requiresConfirmation: z.boolean().optional().describe("Whether host must manually approve bookings"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await createEventTypeHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error creating event type: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 4: update_event_type
+ server.registerTool(
+ "crove_cal_update_event_type",
+ {
+ title: "Update Event Type",
+ description:
+ "Update title, duration, description, or confirmation settings for an existing event type.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe(
+ "Host user ID (tenant scope) — updates are rejected unless the event type belongs to this user"
+ ),
+ id: z.number().describe("Event Type numeric ID"),
+ title: z.string().optional().describe("New title"),
+ slug: z.string().optional().describe("New slug"),
+ length: z.number().optional().describe("New duration in minutes"),
+ description: z.string().optional().describe("New description"),
+ requiresConfirmation: z.boolean().optional().describe("Update confirmation approval flag"),
+ hidden: z.boolean().optional().describe("Whether to hide event type from public profile"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await updateEventTypeHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error updating event type: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 5: delete_event_type
+ server.registerTool(
+ "crove_cal_delete_event_type",
+ {
+ title: "Delete Event Type",
+ description: "Delete an event type by ID.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe(
+ "Host user ID (tenant scope) — deletes are rejected unless the event type belongs to this user"
+ ),
+ id: z.number().describe("Event Type numeric ID to delete"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await deleteEventTypeHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error deleting event type: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 6: get_available_slots
+ server.registerTool(
+ "crove_cal_get_available_slots",
+ {
+ title: "Get Available Slots",
+ description:
+ "Retrieve bookable time slots for an event type between two dates (calculating host availability minus booked meetings).",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — availability is computed for this host"),
+ eventTypeId: z.number().optional().describe("Event Type ID"),
+ slug: z.string().optional().describe("Event Type Slug (e.g., '30min')"),
+ username: z.string().optional().describe("Host username if slug is used"),
+ dateFrom: z.string().describe("Start date in YYYY-MM-DD format"),
+ dateTo: z.string().describe("End date in YYYY-MM-DD format"),
+ timeZone: z.string().optional().describe("Timezone (e.g., 'Asia/Ho_Chi_Minh' or 'UTC')"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await getAvailableSlotsHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error calculating available slots: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 7: create_booking
+ server.registerTool(
+ "crove_cal_create_booking",
+ {
+ title: "Create Booking",
+ description: "Schedule a new booking/meeting in Crove Cal with attendee information.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — the event type must belong to this host"),
+ eventTypeId: z.number().describe("Event Type ID to book"),
+ start: z.string().describe("Booking start time in ISO 8601 format (e.g., '2026-08-30T10:00:00Z')"),
+ name: z.string().describe("Attendee's full name"),
+ email: z.string().describe("Attendee's email address"),
+ timeZone: z.string().optional().describe("Attendee's timezone (e.g., 'Asia/Ho_Chi_Minh')"),
+ notes: z.string().optional().describe("Meeting notes or additional details"),
+ location: z
+ .string()
+ .optional()
+ .describe("Meeting location (e.g., 'Cal Video', 'Google Meet', phone)"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await createBookingHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error creating booking: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 8: get_booking
+ server.registerTool(
+ "crove_cal_get_booking",
+ {
+ title: "Get Booking",
+ description: "Retrieve booking details by booking UID or ID.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — only bookings hosted by this user are returned"),
+ bookingUid: z.string().optional().describe("Booking unique identifier (UID)"),
+ bookingId: z.number().optional().describe("Booking numeric ID"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await getBookingHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error retrieving booking: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 9: reschedule_booking
+ server.registerTool(
+ "crove_cal_reschedule_booking",
+ {
+ title: "Reschedule Booking",
+ description: "Reschedule an existing booking to a new start time.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — only bookings hosted by this user can be rescheduled"),
+ bookingUid: z.string().describe("Booking unique identifier (UID) to reschedule"),
+ newStart: z.string().describe("New start time in ISO 8601 format (e.g., '2026-08-31T14:00:00Z')"),
+ reason: z.string().optional().describe("Reason for rescheduling"),
+ rescheduledBy: z.string().optional().describe("Name/email/agent that requested rescheduling"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await rescheduleBookingHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error rescheduling booking: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 10: cancel_booking
+ server.registerTool(
+ "crove_cal_cancel_booking",
+ {
+ title: "Cancel Booking",
+ description: "Cancel an existing booking and free up the slot.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — only bookings hosted by this user can be cancelled"),
+ bookingUid: z.string().describe("Booking unique identifier (UID) to cancel"),
+ cancellationReason: z.string().optional().describe("Reason for cancellation"),
+ cancelledBy: z.string().optional().describe("Who cancelled the meeting (e.g., 'Customer', 'Agent')"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await cancelBookingHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error cancelling booking: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 11: list_bookings
+ server.registerTool(
+ "crove_cal_list_bookings",
+ {
+ title: "List Bookings",
+ description: "List recent bookings with optional filter by attendee/host email and status.",
+ inputSchema: {
+ userId: z
+ .number()
+ .int()
+ .positive()
+ .describe("Host user ID (tenant scope) — only bookings hosted by this user are listed"),
+ userEmail: z.string().optional().describe("Filter by host or attendee email address"),
+ status: z
+ .enum(["ACCEPTED", "CANCELLED", "PENDING", "REJECTED"])
+ .optional()
+ .describe("Filter by booking status"),
+ limit: z.number().optional().describe("Maximum number of bookings to return (default 20)"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await listBookingsHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error listing bookings: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 12: get_user_profile
+ server.registerTool(
+ "crove_cal_get_user_profile",
+ {
+ title: "Get User Profile",
+ description:
+ "Get user profile details, timezone, default schedule ID, and team memberships in Crove Cal.",
+ inputSchema: {
+ email: z.string().optional().describe("Email address of the user"),
+ username: z.string().optional().describe("Username of the user"),
+ userId: z.number().int().positive().optional().describe("Numeric ID of the user"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await getUserProfileHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error fetching user profile: ${message}` }],
+ };
+ }
+ }
+ );
+
+ // Tool 13: list_schedules
+ server.registerTool(
+ "crove_cal_list_schedules",
+ {
+ title: "List Schedules",
+ description:
+ "Retrieve working hours schedules and daily availability intervals for a user in Crove Cal.",
+ inputSchema: {
+ userId: z.number().int().positive().optional().describe("User ID"),
+ username: z.string().optional().describe("Username"),
+ email: z.string().optional().describe("Email address"),
+ },
+ },
+ async (args) => {
+ try {
+ const result = await listSchedulesHandler(prisma, args);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ isError: true,
+ content: [{ type: "text", text: `Error listing schedules: ${message}` }],
+ };
+ }
+ }
+ );
+
+ return server;
+}
diff --git a/packages/mcp-server/src/tools/bookings.ts b/packages/mcp-server/src/tools/bookings.ts
new file mode 100644
index 00000000000..2c30193ec31
--- /dev/null
+++ b/packages/mcp-server/src/tools/bookings.ts
@@ -0,0 +1,330 @@
+import { randomUUID } from "node:crypto";
+import type { PrismaClient } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+
+export interface CreateBookingInput {
+ /** Host user ID (tenant scope) — the event type must belong to this host */
+ userId: number;
+ eventTypeId: number;
+ start: string; // ISO 8601 string
+ name: string;
+ email: string;
+ timeZone?: string;
+ notes?: string;
+ location?: string;
+}
+
+export async function createBookingHandler(prisma: PrismaClient, input: CreateBookingInput) {
+ const eventType = await prisma.eventType.findFirst({
+ where: { id: input.eventTypeId, userId: input.userId },
+ select: {
+ id: true,
+ title: true,
+ length: true,
+ userId: true,
+ requiresConfirmation: true,
+ owner: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ if (!eventType) {
+ throw new Error(`Event type with ID ${input.eventTypeId} not found for user ${input.userId}`);
+ }
+
+ const startTime = new Date(input.start);
+ if (Number.isNaN(startTime.getTime())) {
+ throw new Error("Invalid start time format. Please provide a valid ISO 8601 date string.");
+ }
+
+ const endTime = new Date(startTime.getTime() + eventType.length * 60 * 1000);
+
+ // Minimal overlap guard for ACCEPTED/PENDING bookings of this event type.
+ const conflict = await prisma.booking.findFirst({
+ where: {
+ eventTypeId: eventType.id,
+ status: { in: ["ACCEPTED", "PENDING"] },
+ startTime: { lt: endTime },
+ endTime: { gt: startTime },
+ },
+ select: {
+ id: true,
+ startTime: true,
+ endTime: true,
+ },
+ });
+
+ if (conflict) {
+ throw new Error(
+ `Time slot already booked: ${conflict.startTime.toISOString()} - ${conflict.endTime.toISOString()}`
+ );
+ }
+
+ // Why: this write bypasses the full booking pipeline in
+ // packages/features/bookings/lib/handleNewBooking (availability calculation, calendar
+ // invites, webhook triggers, workflow reminders) — MCP-created bookings send NO calendar
+ // invitation and fire NO host webhooks. The conflict guard above and the
+ // requiresConfirmation status handling below are minimal guards, not a replacement for
+ // that pipeline.
+ const uid = randomUUID();
+
+ const booking = await prisma.booking.create({
+ data: {
+ uid,
+ title: `${eventType.title} between ${eventType.owner?.name || "Host"} and ${input.name}`,
+ startTime,
+ endTime,
+ description: input.notes || null,
+ location: input.location || "Cal Video",
+ eventTypeId: eventType.id,
+ userId: eventType.userId || eventType.owner?.id,
+ userPrimaryEmail: eventType.owner?.email,
+ status: eventType.requiresConfirmation ? "PENDING" : "ACCEPTED",
+ attendees: {
+ create: {
+ name: input.name,
+ email: input.email.toLowerCase(),
+ timeZone: input.timeZone || "UTC",
+ },
+ },
+ },
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ location: true,
+ status: true,
+ description: true,
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ timeZone: true,
+ },
+ },
+ },
+ });
+
+ return booking;
+}
+
+export interface GetBookingInput {
+ /** Host user ID (tenant scope) — only bookings hosted by this user are returned */
+ userId: number;
+ bookingUid?: string;
+ bookingId?: number;
+}
+
+export async function getBookingHandler(prisma: PrismaClient, input: GetBookingInput) {
+ if (!input.bookingUid && !input.bookingId) {
+ throw new Error("Either bookingUid or bookingId must be provided");
+ }
+
+ const where: Prisma.BookingWhereInput = {
+ userId: input.userId,
+ };
+ if (input.bookingUid) {
+ where.uid = input.bookingUid;
+ } else if (input.bookingId) {
+ where.id = input.bookingId;
+ }
+
+ const booking = await prisma.booking.findFirst({
+ where,
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ location: true,
+ status: true,
+ description: true,
+ cancellationReason: true,
+ cancelledBy: true,
+ rescheduled: true,
+ fromReschedule: true,
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ timeZone: true,
+ },
+ },
+ eventType: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ },
+ },
+ },
+ });
+
+ if (!booking) {
+ throw new Error("Booking not found");
+ }
+
+ return booking;
+}
+
+export interface RescheduleBookingInput {
+ /** Host user ID (tenant scope) — only bookings hosted by this user can be rescheduled */
+ userId: number;
+ bookingUid: string;
+ newStart: string; // ISO 8601 string
+ reason?: string;
+ rescheduledBy?: string;
+}
+
+export async function rescheduleBookingHandler(prisma: PrismaClient, input: RescheduleBookingInput) {
+ const existing = await prisma.booking.findFirst({
+ where: { uid: input.bookingUid, userId: input.userId },
+ select: {
+ id: true,
+ uid: true,
+ userId: true,
+ startTime: true,
+ endTime: true,
+ eventType: {
+ select: { length: true },
+ },
+ },
+ });
+
+ if (!existing) {
+ throw new Error(`Booking with UID ${input.bookingUid} not found for user ${input.userId}`);
+ }
+
+ const newStartTime = new Date(input.newStart);
+ if (Number.isNaN(newStartTime.getTime())) {
+ throw new Error("Invalid newStart format. Please provide a valid ISO 8601 date string.");
+ }
+
+ const durationMs = existing.eventType
+ ? existing.eventType.length * 60 * 1000
+ : existing.endTime.getTime() - existing.startTime.getTime();
+
+ const newEndTime = new Date(newStartTime.getTime() + durationMs);
+
+ const updated = await prisma.booking.update({
+ where: { id: existing.id },
+ data: {
+ startTime: newStartTime,
+ endTime: newEndTime,
+ rescheduled: true,
+ // Why: fromReschedule semantically holds the ORIGINAL booking's UID (every other writer
+ // stores booking.uid — see BookingRepository.findPreviousBooking), not a timestamp.
+ fromReschedule: existing.uid,
+ rescheduledBy: input.rescheduledBy || "AI Agent",
+ status: "ACCEPTED",
+ },
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ status: true,
+ rescheduled: true,
+ fromReschedule: true,
+ },
+ });
+
+ return updated;
+}
+
+export interface CancelBookingInput {
+ /** Host user ID (tenant scope) — only bookings hosted by this user can be cancelled */
+ userId: number;
+ bookingUid: string;
+ cancellationReason?: string;
+ cancelledBy?: string;
+}
+
+export async function cancelBookingHandler(prisma: PrismaClient, input: CancelBookingInput) {
+ const existing = await prisma.booking.findFirst({
+ where: { uid: input.bookingUid, userId: input.userId },
+ select: { id: true, userId: true, status: true },
+ });
+
+ if (!existing) {
+ throw new Error(`Booking with UID ${input.bookingUid} not found for user ${input.userId}`);
+ }
+
+ const cancelled = await prisma.booking.update({
+ where: { id: existing.id },
+ data: {
+ status: "CANCELLED",
+ cancellationReason: input.cancellationReason || "Cancelled via AI Agent",
+ cancelledBy: input.cancelledBy || "AI Agent",
+ },
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ status: true,
+ cancellationReason: true,
+ cancelledBy: true,
+ },
+ });
+
+ return cancelled;
+}
+
+export interface ListBookingsInput {
+ /** Host user ID (tenant scope) — only bookings hosted by this user are listed */
+ userId: number;
+ userEmail?: string;
+ status?: "ACCEPTED" | "CANCELLED" | "PENDING" | "REJECTED";
+ limit?: number;
+}
+
+export async function listBookingsHandler(prisma: PrismaClient, input: ListBookingsInput) {
+ const where: Prisma.BookingWhereInput = {
+ userId: input.userId,
+ };
+
+ if (input.status) {
+ where.status = input.status;
+ }
+
+ if (input.userEmail) {
+ where.OR = [
+ { userPrimaryEmail: { equals: input.userEmail, mode: "insensitive" } },
+ { attendees: { some: { email: { equals: input.userEmail, mode: "insensitive" } } } },
+ ];
+ }
+
+ const bookings = await prisma.booking.findMany({
+ where,
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ status: true,
+ location: true,
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ },
+ },
+ },
+ orderBy: {
+ startTime: "desc",
+ },
+ take: input.limit || 20,
+ });
+
+ return bookings;
+}
diff --git a/packages/mcp-server/src/tools/eventTypes.ts b/packages/mcp-server/src/tools/eventTypes.ts
new file mode 100644
index 00000000000..cebbb169577
--- /dev/null
+++ b/packages/mcp-server/src/tools/eventTypes.ts
@@ -0,0 +1,265 @@
+import type { PrismaClient } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+
+export interface ListEventTypesInput {
+ /** Host user ID (tenant scope) — all returned event types belong to this user */
+ userId: number;
+ username?: string;
+ orgSlug?: string;
+ limit?: number;
+}
+
+export async function listEventTypesHandler(prisma: PrismaClient, input: ListEventTypesInput) {
+ const where: Prisma.EventTypeWhereInput = {
+ hidden: false,
+ userId: input.userId,
+ };
+
+ if (input.username) {
+ where.users = {
+ some: {
+ username: input.username,
+ },
+ };
+ }
+
+ if (input.orgSlug) {
+ where.team = {
+ slug: input.orgSlug,
+ };
+ }
+
+ const eventTypes = await prisma.eventType.findMany({
+ where,
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ description: true,
+ length: true,
+ locations: true,
+ periodType: true,
+ timeZone: true,
+ requiresConfirmation: true,
+ owner: {
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ },
+ },
+ team: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
+ },
+ take: input.limit || 50,
+ });
+
+ return eventTypes;
+}
+
+export interface GetEventTypeDetailsInput {
+ /** Host user ID (tenant scope) — the event type must belong to this user */
+ userId: number;
+ eventTypeId?: number;
+ slug?: string;
+ username?: string;
+}
+
+export async function getEventTypeDetailsHandler(prisma: PrismaClient, input: GetEventTypeDetailsInput) {
+ if (!input.eventTypeId && !input.slug) {
+ throw new Error("Either eventTypeId or slug must be provided");
+ }
+
+ const where: Prisma.EventTypeWhereInput = {
+ userId: input.userId,
+ };
+
+ if (input.eventTypeId) {
+ where.id = input.eventTypeId;
+ } else if (input.slug) {
+ where.slug = input.slug;
+ if (input.username) {
+ where.users = {
+ some: {
+ username: input.username,
+ },
+ };
+ }
+ }
+
+ const eventType = await prisma.eventType.findFirst({
+ where,
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ description: true,
+ length: true,
+ locations: true,
+ periodType: true,
+ timeZone: true,
+ requiresConfirmation: true,
+ bookingFields: true,
+ owner: {
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ },
+ },
+ team: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
+ },
+ });
+
+ if (!eventType) {
+ throw new Error("Event type not found");
+ }
+
+ return eventType;
+}
+
+export interface CreateEventTypeInput {
+ /** Host user ID (tenant scope) — the new event type is created for this user */
+ userId: number;
+ username?: string;
+ title: string;
+ slug: string;
+ length: number; // Duration in minutes
+ description?: string;
+ locations?: Array<{ type: string; link?: string; address?: string }>;
+ requiresConfirmation?: boolean;
+}
+
+export async function createEventTypeHandler(prisma: PrismaClient, input: CreateEventTypeInput) {
+ let targetUserId = input.userId;
+
+ if (!targetUserId && input.username) {
+ const user = await prisma.user.findFirst({
+ where: { username: input.username },
+ select: { id: true },
+ });
+ if (user) targetUserId = user.id;
+ }
+
+ if (!targetUserId) {
+ throw new Error(
+ "User ID is required to create an event type: provide userId or a username that resolves to an existing user"
+ );
+ }
+
+ const newEventType = await prisma.eventType.create({
+ data: {
+ title: input.title,
+ slug: input.slug,
+ length: input.length,
+ description: input.description || null,
+ locations: input.locations || [{ type: "integrations:daily" }],
+ requiresConfirmation: input.requiresConfirmation || false,
+ userId: targetUserId,
+ users: {
+ connect: [{ id: targetUserId }],
+ },
+ },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ length: true,
+ description: true,
+ locations: true,
+ requiresConfirmation: true,
+ userId: true,
+ },
+ });
+
+ return newEventType;
+}
+
+export interface UpdateEventTypeInput {
+ /** Host user ID (tenant scope) — updates are rejected unless the event type belongs to this user */
+ userId: number;
+ id: number;
+ title?: string;
+ slug?: string;
+ length?: number;
+ description?: string;
+ requiresConfirmation?: boolean;
+ hidden?: boolean;
+}
+
+export async function updateEventTypeHandler(prisma: PrismaClient, input: UpdateEventTypeInput) {
+ const existing = await prisma.eventType.findFirst({
+ where: { id: input.id, userId: input.userId },
+ select: { id: true },
+ });
+
+ if (!existing) {
+ throw new Error(`Event type with ID ${input.id} not found for user ${input.userId}`);
+ }
+
+ const updated = await prisma.eventType.update({
+ where: { id: input.id, userId: input.userId },
+ data: {
+ ...(input.title !== undefined ? { title: input.title } : {}),
+ ...(input.slug !== undefined ? { slug: input.slug } : {}),
+ ...(input.length !== undefined ? { length: input.length } : {}),
+ ...(input.description !== undefined ? { description: input.description } : {}),
+ ...(input.requiresConfirmation !== undefined
+ ? { requiresConfirmation: input.requiresConfirmation }
+ : {}),
+ ...(input.hidden !== undefined ? { hidden: input.hidden } : {}),
+ },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ length: true,
+ description: true,
+ requiresConfirmation: true,
+ hidden: true,
+ },
+ });
+
+ return updated;
+}
+
+export interface DeleteEventTypeInput {
+ /** Host user ID (tenant scope) — deletes are rejected unless the event type belongs to this user */
+ userId: number;
+ id: number;
+}
+
+export async function deleteEventTypeHandler(prisma: PrismaClient, input: DeleteEventTypeInput) {
+ const existing = await prisma.eventType.findFirst({
+ where: { id: input.id, userId: input.userId },
+ select: { id: true },
+ });
+
+ if (!existing) {
+ throw new Error(`Event type with ID ${input.id} not found for user ${input.userId}`);
+ }
+
+ const deleted = await prisma.eventType.delete({
+ where: { id: input.id, userId: input.userId },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ },
+ });
+
+ return deleted;
+}
diff --git a/packages/mcp-server/src/tools/slots.ts b/packages/mcp-server/src/tools/slots.ts
new file mode 100644
index 00000000000..01f4a6d1603
--- /dev/null
+++ b/packages/mcp-server/src/tools/slots.ts
@@ -0,0 +1,194 @@
+import type { PrismaClient } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+
+export interface GetAvailableSlotsInput {
+ /** Host user ID (tenant scope) — availability is computed for this host */
+ userId: number;
+ eventTypeId?: number;
+ slug?: string;
+ username?: string;
+ dateFrom: string; // YYYY-MM-DD
+ dateTo: string; // YYYY-MM-DD
+ timeZone?: string;
+}
+
+export interface TimeSlot {
+ time: string; // ISO 8601 string
+}
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+const MAX_DATE_RANGE_DAYS = 60;
+
+export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetAvailableSlotsInput) {
+ if (!input.eventTypeId && !input.slug) {
+ throw new Error("Either eventTypeId or slug must be provided");
+ }
+
+ const where: Prisma.EventTypeWhereInput = {
+ userId: input.userId,
+ };
+ if (input.eventTypeId) {
+ where.id = input.eventTypeId;
+ } else if (input.slug) {
+ where.slug = input.slug;
+ if (input.username) {
+ where.users = { some: { username: input.username } };
+ }
+ }
+
+ const eventType = await prisma.eventType.findFirst({
+ where,
+ select: {
+ id: true,
+ length: true,
+ timeZone: true,
+ userId: true,
+ owner: {
+ select: {
+ id: true,
+ email: true,
+ defaultScheduleId: true,
+ },
+ },
+ },
+ });
+
+ if (!eventType) {
+ throw new Error("Event type not found");
+ }
+
+ const hostUserId = eventType.userId ?? eventType.owner?.id;
+ if (!hostUserId) {
+ throw new Error(
+ `Event type with ID ${eventType.id} has no owning user, so host availability cannot be determined`
+ );
+ }
+
+ const startDate = new Date(`${input.dateFrom}T00:00:00.000Z`);
+ const endDate = new Date(`${input.dateTo}T23:59:59.999Z`);
+
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
+ throw new Error("Invalid dateFrom or dateTo format. Please use YYYY-MM-DD");
+ }
+
+ const spanDays = Math.round(
+ (new Date(`${input.dateTo}T00:00:00.000Z`).getTime() -
+ new Date(`${input.dateFrom}T00:00:00.000Z`).getTime()) /
+ MS_PER_DAY
+ );
+ if (spanDays > MAX_DATE_RANGE_DAYS) {
+ throw new Error(
+ `Date range too large: dateFrom to dateTo spans ${spanDays} days, maximum is ${MAX_DATE_RANGE_DAYS}`
+ );
+ }
+
+ const schedule = await prisma.schedule.findFirst({
+ where: { userId: hostUserId },
+ select: {
+ timeZone: true,
+ availability: {
+ select: {
+ days: true,
+ startTime: true,
+ endTime: true,
+ },
+ },
+ },
+ });
+
+ // Availability windows are only used when the schedule is in UTC — converting non-UTC
+ // windows would require DST-aware timezone math that is out of scope here.
+ const scheduleHasUtcWindows = Boolean(
+ schedule && (schedule.timeZone === "UTC" || schedule.timeZone === "Etc/UTC")
+ );
+
+ // Get the host's non-cancelled bookings in the date range (across ALL of their event
+ // types, not just the requested one — the host cannot attend two meetings at once).
+ const existingBookings = await prisma.booking.findMany({
+ where: {
+ userId: hostUserId,
+ status: { notIn: ["CANCELLED", "REJECTED"] },
+ startTime: { lte: endDate },
+ endTime: { gte: startDate },
+ },
+ select: {
+ startTime: true,
+ endTime: true,
+ },
+ });
+
+ const bookedIntervals = existingBookings.map((b) => ({
+ start: new Date(b.startTime).getTime(),
+ end: new Date(b.endTime).getTime(),
+ }));
+
+ const durationMs = eventType.length * 60 * 1000;
+ const slots: TimeSlot[] = [];
+
+ // Generate candidate slots per day inside the host's working windows
+ const currentDay = new Date(startDate);
+ while (currentDay <= endDate) {
+ const dayStart = new Date(currentDay);
+ dayStart.setUTCHours(0, 0, 0, 0);
+
+ const dayWindows: Array<{ start: number; end: number }> = [];
+
+ if (scheduleHasUtcWindows && schedule) {
+ // Cal.com availability.days uses the JS convention: 0 = Sunday ... 6 = Saturday.
+ const dayOfWeek = dayStart.getUTCDay();
+ for (const availabilityRow of schedule.availability) {
+ if (!availabilityRow.days.includes(dayOfWeek)) {
+ continue;
+ }
+ // Time columns come back anchored to 1970-01-01, but derive offsets from the
+ // clock fields so the math does not depend on that anchor.
+ const windowStart =
+ dayStart.getTime() +
+ availabilityRow.startTime.getUTCHours() * 3_600_000 +
+ availabilityRow.startTime.getUTCMinutes() * 60_000;
+ const windowEnd =
+ dayStart.getTime() +
+ availabilityRow.endTime.getUTCHours() * 3_600_000 +
+ availabilityRow.endTime.getUTCMinutes() * 60_000;
+ if (windowEnd > windowStart) {
+ dayWindows.push({ start: windowStart, end: windowEnd });
+ }
+ }
+ } else {
+ // Why: the host has no schedule (or it is not UTC-based); converting a non-UTC
+ // schedule's windows would need full timezone/DST handling, so fall back to a
+ // fixed 09:00-17:00 UTC working window to keep slot generation deterministic.
+ const fallbackStart = new Date(dayStart);
+ fallbackStart.setUTCHours(9, 0, 0, 0);
+ const fallbackEnd = new Date(dayStart);
+ fallbackEnd.setUTCHours(17, 0, 0, 0);
+ dayWindows.push({ start: fallbackStart.getTime(), end: fallbackEnd.getTime() });
+ }
+
+ for (const window of dayWindows) {
+ let slotStart = window.start;
+ while (slotStart + durationMs <= window.end) {
+ const slotEnd = slotStart + durationMs;
+
+ // Check if slot overlaps with any booked intervals
+ const isOverlap = bookedIntervals.some((b) => slotStart < b.end && slotEnd > b.start);
+
+ if (!isOverlap) {
+ slots.push({
+ time: new Date(slotStart).toISOString(),
+ });
+ }
+
+ slotStart += durationMs;
+ }
+ }
+
+ currentDay.setUTCDate(currentDay.getUTCDate() + 1);
+ }
+
+ return {
+ eventTypeId: eventType.id,
+ length: eventType.length,
+ slots,
+ };
+}
diff --git a/packages/mcp-server/src/tools/users.ts b/packages/mcp-server/src/tools/users.ts
new file mode 100644
index 00000000000..b4107d81f68
--- /dev/null
+++ b/packages/mcp-server/src/tools/users.ts
@@ -0,0 +1,124 @@
+import type { PrismaClient } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+
+export interface GetUserProfileInput {
+ email?: string;
+ username?: string;
+ userId?: number;
+}
+
+export async function getUserProfileHandler(prisma: PrismaClient, input: GetUserProfileInput) {
+ if (!input.email && !input.username && !input.userId) {
+ throw new Error("Either email, username, or userId must be provided");
+ }
+
+ const where: Prisma.UserWhereInput = {};
+
+ if (input.userId) {
+ where.id = input.userId;
+ } else if (input.email) {
+ where.email = { equals: input.email, mode: "insensitive" };
+ } else if (input.username) {
+ where.username = input.username;
+ }
+
+ const user = await prisma.user.findFirst({
+ where,
+ select: {
+ id: true,
+ username: true,
+ name: true,
+ email: true,
+ timeZone: true,
+ weekStart: true,
+ locale: true,
+ avatarUrl: true,
+ defaultScheduleId: true,
+ teams: {
+ select: {
+ role: true,
+ accepted: true,
+ team: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ isOrganization: true,
+ metadata: true,
+ },
+ },
+ },
+ },
+ profiles: {
+ select: {
+ id: true,
+ uid: true,
+ username: true,
+ organizationId: true,
+ },
+ },
+ },
+ });
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ return user;
+}
+
+export interface ListSchedulesInput {
+ userId?: number;
+ username?: string;
+ email?: string;
+}
+
+export async function listSchedulesHandler(prisma: PrismaClient, input: ListSchedulesInput) {
+ let targetUserId = input.userId;
+
+ if (!targetUserId) {
+ if (input.email || input.username) {
+ // An empty object inside OR matches every row, so only push clauses that are actually defined
+ const identityClauses: Prisma.UserWhereInput[] = [];
+ if (input.email) {
+ identityClauses.push({ email: { equals: input.email, mode: "insensitive" } });
+ }
+ if (input.username) {
+ identityClauses.push({ username: input.username });
+ }
+ const user = await prisma.user.findFirst({
+ where: { OR: identityClauses },
+ select: { id: true },
+ });
+ if (user) {
+ targetUserId = user.id;
+ }
+ }
+ }
+
+ if (!targetUserId) {
+ throw new Error("User not found or userId / username / email must be provided");
+ }
+
+ const schedules = await prisma.schedule.findMany({
+ where: {
+ userId: targetUserId,
+ },
+ select: {
+ id: true,
+ name: true,
+ timeZone: true,
+ availability: {
+ select: {
+ id: true,
+ days: true,
+ startTime: true,
+ endTime: true,
+ date: true,
+ },
+ },
+ },
+ });
+
+ return schedules;
+}
diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json
new file mode 100644
index 00000000000..78d61269b90
--- /dev/null
+++ b/packages/mcp-server/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@calcom/tsconfig/base.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*", "bin/**/*"],
+ "exclude": ["dist", "build", "**/node_modules/**", "**/*.test.ts", "**/__tests__/**"]
+}
diff --git a/packages/platform/atoms/package.json b/packages/platform/atoms/package.json
index 9b4cd6eb581..ebd3509eacf 100644
--- a/packages/platform/atoms/package.json
+++ b/packages/platform/atoms/package.json
@@ -37,7 +37,7 @@
"postcss-prefixer": "3.0.0",
"postcss-prefixwrap": "1.57.0",
"ts-jest": "29.1.4",
- "typescript": "5.9.3",
+ "typescript": "6.0.3",
"vite": "6.4.2",
"vite-plugin-dts": "4.5.4",
"vite-plugin-inspect": "0.8.4"
diff --git a/packages/platform/constants/package.json b/packages/platform/constants/package.json
index bb3c3ff3b80..743e4f61d28 100644
--- a/packages/platform/constants/package.json
+++ b/packages/platform/constants/package.json
@@ -8,5 +8,8 @@
"build": "tsc --build --force tsconfig.json",
"build:watch": "tsc --build --force ./tsconfig.json --watch",
"post-install": "yarn build"
+ },
+ "devDependencies": {
+ "typescript": "6.0.3"
}
}
diff --git a/packages/platform/constants/permissions.ts b/packages/platform/constants/permissions.ts
index 018cdbce2ce..20fe8a20ef0 100644
--- a/packages/platform/constants/permissions.ts
+++ b/packages/platform/constants/permissions.ts
@@ -22,6 +22,8 @@ export const PERMISSIONS = [
PROFILE_WRITE,
] as const;
+export type PLATFORM_PERMISSION = (typeof PERMISSIONS)[number];
+
export const PERMISSION_MAP = {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
diff --git a/packages/platform/constants/tsconfig.json b/packages/platform/constants/tsconfig.json
index 129d25f1c15..8bb403275c6 100644
--- a/packages/platform/constants/tsconfig.json
+++ b/packages/platform/constants/tsconfig.json
@@ -1,7 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
- "target": "ES5",
+ "target": "ES2022",
"resolveJsonModule": true,
"baseUrl": "./",
"outDir": "./dist"
diff --git a/packages/platform/enums/package.json b/packages/platform/enums/package.json
index 13b046761b9..2d4d0096292 100644
--- a/packages/platform/enums/package.json
+++ b/packages/platform/enums/package.json
@@ -23,5 +23,8 @@
},
"dependencies": {
"@calcom/platform-constants": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "6.0.3"
}
}
diff --git a/packages/platform/enums/permissions.ts b/packages/platform/enums/permissions.ts
index 926b7300cd3..f0b91ad06f0 100644
--- a/packages/platform/enums/permissions.ts
+++ b/packages/platform/enums/permissions.ts
@@ -10,8 +10,8 @@ import {
APPS_WRITE,
PROFILE_READ,
PROFILE_WRITE,
+ type PLATFORM_PERMISSION,
} from "@calcom/platform-constants";
-import type { PLATFORM_PERMISSION } from "@calcom/platform-types";
export const hasPermission = (userPermissions: number, permission: PLATFORM_PERMISSION): boolean => {
// use bitwise AND to check if user has the permission
diff --git a/packages/platform/enums/tsconfig.json b/packages/platform/enums/tsconfig.json
index c6e4a125526..73525a1c711 100644
--- a/packages/platform/enums/tsconfig.json
+++ b/packages/platform/enums/tsconfig.json
@@ -1,7 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
- "target": "ES5",
+ "target": "ES2022",
"resolveJsonModule": true,
"experimentalDecorators": true,
"baseUrl": "./",
diff --git a/packages/platform/examples/base/package.json b/packages/platform/examples/base/package.json
index 229c82e4c6e..fecdd9a96b4 100644
--- a/packages/platform/examples/base/package.json
+++ b/packages/platform/examples/base/package.json
@@ -13,7 +13,7 @@
"dependencies": {
"@calcom/atoms": "workspace:*",
"@prisma/client": "6.16.1",
- "next": "16.2.3",
+ "next": "16.3.3",
"prisma": "6.16.1",
"react": "18.2.0",
"react-dom": "18.2.0",
@@ -28,6 +28,6 @@
"dotenv": "17.2.2",
"postcss": "8.5.6",
"tailwindcss": "4.1.17",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/platform/libraries/package.json b/packages/platform/libraries/package.json
index 786651256e9..47bb95e8854 100644
--- a/packages/platform/libraries/package.json
+++ b/packages/platform/libraries/package.json
@@ -30,7 +30,7 @@
"devDependencies": {
"@types/node": "20.17.23",
"@vitejs/plugin-react": "5.1.2",
- "typescript": "5.9.3",
+ "typescript": "6.0.3",
"vite": "6.4.2",
"vite-plugin-dts": "4.5.4",
"vite-plugin-environment": "1.1.3"
diff --git a/packages/platform/types/package.json b/packages/platform/types/package.json
index f89a355c8a9..497d53cf6e7 100644
--- a/packages/platform/types/package.json
+++ b/packages/platform/types/package.json
@@ -22,6 +22,7 @@
"zod": "3.25.76"
},
"devDependencies": {
- "@types/express": "4.17.21"
+ "@types/express": "4.17.21",
+ "typescript": "6.0.3"
}
}
diff --git a/packages/platform/types/tsconfig.json b/packages/platform/types/tsconfig.json
index 856bd098765..84a0bc85ea4 100644
--- a/packages/platform/types/tsconfig.json
+++ b/packages/platform/types/tsconfig.json
@@ -1,7 +1,9 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
- "target": "ES5",
+ "target": "ES2021",
+ "useDefineForClassFields": false,
+ "isolatedModules": false,
"resolveJsonModule": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
diff --git a/packages/platform/utils/package.json b/packages/platform/utils/package.json
index 78535306232..41ce2f6b792 100644
--- a/packages/platform/utils/package.json
+++ b/packages/platform/utils/package.json
@@ -17,6 +17,7 @@
"devDependencies": {
"@types/jest": "29.5.12",
"jest": "29.7.0",
- "ts-jest": "29.1.4"
+ "ts-jest": "29.1.4",
+ "typescript": "6.0.3"
}
}
diff --git a/packages/platform/utils/tsconfig.json b/packages/platform/utils/tsconfig.json
index ba98db2d60a..d2083e802ec 100644
--- a/packages/platform/utils/tsconfig.json
+++ b/packages/platform/utils/tsconfig.json
@@ -1,7 +1,9 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
- "target": "ES5",
+ "target": "ES2021",
+ "useDefineForClassFields": false,
+ "isolatedModules": false,
"resolveJsonModule": true,
"types": ["jest"],
"outDir": "./dist",
diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts
index 1c7a844a55d..bf788f2a80b 100644
--- a/packages/prisma/index.ts
+++ b/packages/prisma/index.ts
@@ -7,17 +7,46 @@ import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users";
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
import { PrismaClient, type Prisma } from "./generated/prisma/client";
+export function getSchemaFromUrl(url: string | undefined): string | undefined {
+ if (!url) return undefined;
+ try {
+ const parsed = new URL(url.replace(/^postgresql:\/\//, "http://").replace(/^postgres:\/\//, "http://"));
+ return parsed.searchParams.get("schema") || undefined;
+ } catch {
+ const match = url.match(/[?&]schema=([^&]+)/);
+ return match ? match[1] : undefined;
+ }
+}
+
+// TLS verification defaults to on; endpoints whose certificate Node cannot verify
+// (e.g. self-signed or Supabase pooler certs) must explicitly opt out via
+// DATABASE_SSL_REJECT_UNAUTHORIZED=false so an insecure default cannot ship silently.
+export function resolveDatabaseSsl(): { rejectUnauthorized: boolean } {
+ return {
+ rejectUnauthorized: process.env.DATABASE_SSL_REJECT_UNAUTHORIZED !== "false",
+ };
+}
+
const connectionString = process.env.DATABASE_URL || "";
+const schema = getSchemaFromUrl(connectionString);
+const adapterOptions = schema ? { schema } : undefined;
+
const pool =
process.env.USE_POOL === "true" || process.env.USE_POOL === "1"
? new Pool({
connectionString: connectionString,
max: 5,
idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
})
- : undefined;
+ : new Pool({
+ connectionString: connectionString,
+ max: 10,
+ idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
+ });
-const adapter = pool ? new PrismaPg(pool) : new PrismaPg({ connectionString });
+const adapter = new PrismaPg(pool, adapterOptions);
const prismaOptions: Prisma.PrismaClientOptions = {
adapter,
};
@@ -52,7 +81,14 @@ export const customPrisma = (options?: Prisma.PrismaClientOptions) => {
if (options?.datasources?.db?.url) {
const customConnectionString = options.datasources.db.url;
- const customAdapter = new PrismaPg({ connectionString: customConnectionString });
+ const customSchema = getSchemaFromUrl(customConnectionString);
+ const customPool = new Pool({
+ connectionString: customConnectionString,
+ max: 5,
+ idleTimeoutMillis: 300000,
+ ssl: resolveDatabaseSsl(),
+ });
+ const customAdapter = new PrismaPg(customPool, customSchema ? { schema: customSchema } : undefined);
const { datasources: _datasources, ...restOptions } = options;
finalOptions = {
@@ -104,6 +140,7 @@ export type {
OmitPrismaClient as PrismaTransaction,
// we re-export the native PrismaClient type for backwards-compatibility.
PrismaClient,
+ Prisma,
};
/**
diff --git a/packages/prisma/migrations/20230418002117_booking_time_status/migration.sql b/packages/prisma/migrations/20230418002117_booking_time_status/migration.sql
index 5c628d044e1..181592ab96c 100644
--- a/packages/prisma/migrations/20230418002117_booking_time_status/migration.sql
+++ b/packages/prisma/migrations/20230418002117_booking_time_status/migration.sql
@@ -2,7 +2,7 @@
-- DROP VIEW public."BookingsTimeStatus";
-CREATE OR REPLACE VIEW public."BookingTimeStatus"
+CREATE OR REPLACE VIEW "BookingTimeStatus"
AS
SELECT "Booking".id,
"Booking".uid,
diff --git a/packages/prisma/migrations/20230719214513_update_booking_time_status_fields_event_parent_id/migration.sql b/packages/prisma/migrations/20230719214513_update_booking_time_status_fields_event_parent_id/migration.sql
index 0d523916549..66e78206d2b 100644
--- a/packages/prisma/migrations/20230719214513_update_booking_time_status_fields_event_parent_id/migration.sql
+++ b/packages/prisma/migrations/20230719214513_update_booking_time_status_fields_event_parent_id/migration.sql
@@ -2,7 +2,7 @@
-- DROP VIEW public."BookingsTimeStatus";
-CREATE OR REPLACE VIEW public."BookingTimeStatus"
+CREATE OR REPLACE VIEW "BookingTimeStatus"
AS
SELECT "Booking".id,
"Booking".uid,
diff --git a/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql b/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql
index 0fbf5d5e868..3eefca6cebf 100644
--- a/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql
+++ b/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql
@@ -2,7 +2,7 @@
-- DROP VIEW public."BookingsTimeStatus";
-CREATE OR REPLACE VIEW public."BookingTimeStatus"
+CREATE OR REPLACE VIEW "BookingTimeStatus"
AS
SELECT "Booking".id,
"Booking".uid,
diff --git a/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql b/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql
index 38a68f839ad..515fa0915c2 100644
--- a/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql
+++ b/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql
@@ -2,7 +2,7 @@
-- DROP VIEW public."BookingTimeStatus";
-CREATE OR REPLACE VIEW public."BookingTimeStatus"
+CREATE OR REPLACE VIEW "BookingTimeStatus"
AS
SELECT "Booking".id,
"Booking".uid,
diff --git a/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql b/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql
index 71ccaec2c4b..fcf163c0282 100644
--- a/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql
+++ b/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql
@@ -1,4 +1,4 @@
-CREATE OR REPLACE VIEW public."BookingTimeStatus"
+CREATE OR REPLACE VIEW "BookingTimeStatus"
AS
SELECT
"Booking".id,
diff --git a/packages/prisma/migrations/20240909162522_union_insights_data/migration.sql b/packages/prisma/migrations/20240909162522_union_insights_data/migration.sql
index 87b9e420308..aca92db1b05 100644
--- a/packages/prisma/migrations/20240909162522_union_insights_data/migration.sql
+++ b/packages/prisma/migrations/20240909162522_union_insights_data/migration.sql
@@ -1,4 +1,4 @@
-CREATE OR REPLACE VIEW public."BookingTimeStatus"
+CREATE OR REPLACE VIEW "BookingTimeStatus"
AS
SELECT
"Booking".id,
diff --git a/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql b/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql
index 693d62a03bd..0c45160b5c4 100644
--- a/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql
+++ b/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql
@@ -1,11 +1,11 @@
-CREATE OR REPLACE VIEW public."BookingTimeStatusDenormalized" AS
+CREATE OR REPLACE VIEW "BookingTimeStatusDenormalized" AS
SELECT
*,
CASE
WHEN "rescheduled" IS TRUE THEN 'rescheduled'
- WHEN "status" = 'cancelled'::public."BookingStatus" AND "rescheduled" IS NULL THEN 'cancelled'
+ WHEN "status" = 'cancelled'::"BookingStatus" AND "rescheduled" IS NULL THEN 'cancelled'
WHEN "endTime" < now() THEN 'completed'
WHEN "endTime" > now() THEN 'uncompleted'
ELSE NULL
END as "timeStatus"
-FROM public."BookingDenormalized";
+FROM "BookingDenormalized";
diff --git a/packages/prisma/migrations/20250925122623_add_default_now_eventtype_table_created_at/migration.sql b/packages/prisma/migrations/20250925122623_add_default_now_eventtype_table_created_at/migration.sql
index 37d45c08dd8..0629323a6d0 100644
--- a/packages/prisma/migrations/20250925122623_add_default_now_eventtype_table_created_at/migration.sql
+++ b/packages/prisma/migrations/20250925122623_add_default_now_eventtype_table_created_at/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."EventType" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;
+ALTER TABLE "EventType" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;
diff --git a/packages/prisma/migrations/20251023133244_add_user_uuid_column/migration.sql b/packages/prisma/migrations/20251023133244_add_user_uuid_column/migration.sql
index f59a6784a2d..ca922522772 100644
--- a/packages/prisma/migrations/20251023133244_add_user_uuid_column/migration.sql
+++ b/packages/prisma/migrations/20251023133244_add_user_uuid_column/migration.sql
@@ -5,10 +5,10 @@
*/
-- AlterTable
-ALTER TABLE "public"."users" ADD COLUMN "uuid" UUID;
+ALTER TABLE "users" ADD COLUMN "uuid" UUID;
-- CreateIndex
-CREATE UNIQUE INDEX "users_uuid_key" ON "public"."users"("uuid");
+CREATE UNIQUE INDEX "users_uuid_key" ON "users"("uuid");
-- Backfill UUIDs in batches to avoid table locking on large datasets
-- Uses FOR UPDATE SKIP LOCKED to prevent blocking other transactions
@@ -22,15 +22,15 @@ BEGIN
LOOP
WITH batch AS (
SELECT id
- FROM "public"."users"
+ FROM "users"
WHERE uuid IS NULL
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
- UPDATE "public"."users"
+ UPDATE "users"
SET uuid = gen_random_uuid()
FROM batch
- WHERE "public"."users".id = batch.id;
+ WHERE "users".id = batch.id;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
total_updated := total_updated + rows_updated;
diff --git a/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql b/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql
index 93180334da1..6b8e829ab9c 100644
--- a/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql
+++ b/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."Webhook" ADD COLUMN "version" TEXT NOT NULL DEFAULT '2021-10-20';
+ALTER TABLE "Webhook" ADD COLUMN "version" TEXT NOT NULL DEFAULT '2021-10-20';
diff --git a/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql b/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql
index b3e3708ff20..2658ec20ecb 100644
--- a/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql
+++ b/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql
@@ -1,8 +1,8 @@
-- AlterTable
-ALTER TABLE "public"."CalVideoSettings" ADD COLUMN "requireEmailForGuests" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "CalVideoSettings" ADD COLUMN "requireEmailForGuests" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
-CREATE TABLE "public"."VideoCallGuest" (
+CREATE TABLE "VideoCallGuest" (
"id" TEXT NOT NULL,
"bookingUid" TEXT NOT NULL,
"email" TEXT NOT NULL,
@@ -15,10 +15,10 @@ CREATE TABLE "public"."VideoCallGuest" (
);
-- CreateIndex
-CREATE INDEX "VideoCallGuest_bookingUid_idx" ON "public"."VideoCallGuest"("bookingUid");
+CREATE INDEX "VideoCallGuest_bookingUid_idx" ON "VideoCallGuest"("bookingUid");
-- CreateIndex
-CREATE INDEX "VideoCallGuest_email_idx" ON "public"."VideoCallGuest"("email");
+CREATE INDEX "VideoCallGuest_email_idx" ON "VideoCallGuest"("email");
-- CreateIndex
-CREATE UNIQUE INDEX "VideoCallGuest_bookingUid_email_key" ON "public"."VideoCallGuest"("bookingUid", "email");
+CREATE UNIQUE INDEX "VideoCallGuest_bookingUid_email_key" ON "VideoCallGuest"("bookingUid", "email");
diff --git a/packages/prisma/migrations/20251030081154_add_report_status/migration.sql b/packages/prisma/migrations/20251030081154_add_report_status/migration.sql
index 95ff3ea8003..6f0d8c39258 100644
--- a/packages/prisma/migrations/20251030081154_add_report_status/migration.sql
+++ b/packages/prisma/migrations/20251030081154_add_report_status/migration.sql
@@ -1,5 +1,5 @@
-- CreateEnum
-CREATE TYPE "public"."BookingReportStatus" AS ENUM ('PENDING', 'DISMISSED', 'BLOCKED');
+CREATE TYPE "BookingReportStatus" AS ENUM ('PENDING', 'DISMISSED', 'BLOCKED');
-- AlterTable
-ALTER TABLE "public"."BookingReport" ADD COLUMN "status" "public"."BookingReportStatus" NOT NULL DEFAULT 'PENDING';
+ALTER TABLE "BookingReport" ADD COLUMN "status" "BookingReportStatus" NOT NULL DEFAULT 'PENDING';
diff --git a/packages/prisma/migrations/20251103175338_make_user_uuid_required/migration.sql b/packages/prisma/migrations/20251103175338_make_user_uuid_required/migration.sql
index a0f783cbf60..5a5a970ff32 100644
--- a/packages/prisma/migrations/20251103175338_make_user_uuid_required/migration.sql
+++ b/packages/prisma/migrations/20251103175338_make_user_uuid_required/migration.sql
@@ -5,4 +5,4 @@
*/
-- AlterTable
-ALTER TABLE "public"."users" ALTER COLUMN "uuid" SET NOT NULL;
+ALTER TABLE "users" ALTER COLUMN "uuid" SET NOT NULL;
diff --git a/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql b/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql
index 5477249efd3..9318b4e736b 100644
--- a/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql
+++ b/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql
@@ -1,2 +1,2 @@
-- AlterEnum
-ALTER TYPE "public"."WebhookTriggerEvents" ADD VALUE 'DELEGATION_CREDENTIAL_ERROR';
+ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DELEGATION_CREDENTIAL_ERROR';
diff --git a/packages/prisma/migrations/20251110160435_add_outbound_event_type_id_to_agent/migration.sql b/packages/prisma/migrations/20251110160435_add_outbound_event_type_id_to_agent/migration.sql
index 2a16aef040c..bc4f1605a4f 100644
--- a/packages/prisma/migrations/20251110160435_add_outbound_event_type_id_to_agent/migration.sql
+++ b/packages/prisma/migrations/20251110160435_add_outbound_event_type_id_to_agent/migration.sql
@@ -1,5 +1,5 @@
-- AlterTable
-ALTER TABLE "public"."Agent" ADD COLUMN "outboundEventTypeId" INTEGER;
+ALTER TABLE "Agent" ADD COLUMN "outboundEventTypeId" INTEGER;
-- CreateIndex
-CREATE INDEX "Agent_outboundEventTypeId_idx" ON "public"."Agent"("outboundEventTypeId");
+CREATE INDEX "Agent_outboundEventTypeId_idx" ON "Agent"("outboundEventTypeId");
diff --git a/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql b/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql
index 294eaef1519..2b660f5de38 100644
--- a/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql
+++ b/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "orgAutoJoinOnSignup" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "OrganizationSettings" ADD COLUMN "orgAutoJoinOnSignup" BOOLEAN NOT NULL DEFAULT true;
diff --git a/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql b/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql
index 70633dd858c..9f8c6d89c6c 100644
--- a/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql
+++ b/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql
@@ -1,16 +1,16 @@
-- CreateEnum
-CREATE TYPE "public"."BookingAuditType" AS ENUM ('record_created', 'record_updated', 'record_deleted');
+CREATE TYPE "BookingAuditType" AS ENUM ('record_created', 'record_updated', 'record_deleted');
-- CreateEnum
-CREATE TYPE "public"."BookingAuditAction" AS ENUM ('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');
+CREATE TYPE "BookingAuditAction" AS ENUM ('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');
-- CreateEnum
-CREATE TYPE "public"."AuditActorType" AS ENUM ('user', 'guest', 'attendee', 'system');
+CREATE TYPE "AuditActorType" AS ENUM ('user', 'guest', 'attendee', 'system');
-- CreateTable
-CREATE TABLE "public"."AuditActor" (
+CREATE TABLE "AuditActor" (
"id" TEXT NOT NULL,
- "type" "public"."AuditActorType" NOT NULL,
+ "type" "AuditActorType" NOT NULL,
"userUuid" UUID,
"attendeeId" INTEGER,
"email" TEXT,
@@ -22,12 +22,12 @@ CREATE TABLE "public"."AuditActor" (
);
-- CreateTable
-CREATE TABLE "public"."BookingAudit" (
+CREATE TABLE "BookingAudit" (
"id" UUID NOT NULL,
"bookingUid" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
- "type" "public"."BookingAuditType" NOT NULL,
- "action" "public"."BookingAuditAction" NOT NULL,
+ "type" "BookingAuditType" NOT NULL,
+ "action" "BookingAuditAction" NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -37,34 +37,34 @@ CREATE TABLE "public"."BookingAudit" (
);
-- CreateIndex
-CREATE INDEX "AuditActor_email_idx" ON "public"."AuditActor"("email");
+CREATE INDEX "AuditActor_email_idx" ON "AuditActor"("email");
-- CreateIndex
-CREATE INDEX "AuditActor_userUuid_idx" ON "public"."AuditActor"("userUuid");
+CREATE INDEX "AuditActor_userUuid_idx" ON "AuditActor"("userUuid");
-- CreateIndex
-CREATE INDEX "AuditActor_attendeeId_idx" ON "public"."AuditActor"("attendeeId");
+CREATE INDEX "AuditActor_attendeeId_idx" ON "AuditActor"("attendeeId");
-- CreateIndex
-CREATE UNIQUE INDEX "AuditActor_userUuid_key" ON "public"."AuditActor"("userUuid");
+CREATE UNIQUE INDEX "AuditActor_userUuid_key" ON "AuditActor"("userUuid");
-- CreateIndex
-CREATE UNIQUE INDEX "AuditActor_attendeeId_key" ON "public"."AuditActor"("attendeeId");
+CREATE UNIQUE INDEX "AuditActor_attendeeId_key" ON "AuditActor"("attendeeId");
-- CreateIndex
-CREATE UNIQUE INDEX "AuditActor_email_key" ON "public"."AuditActor"("email");
+CREATE UNIQUE INDEX "AuditActor_email_key" ON "AuditActor"("email");
-- CreateIndex
-CREATE UNIQUE INDEX "AuditActor_phone_key" ON "public"."AuditActor"("phone");
+CREATE UNIQUE INDEX "AuditActor_phone_key" ON "AuditActor"("phone");
-- CreateIndex
-CREATE INDEX "BookingAudit_actorId_idx" ON "public"."BookingAudit"("actorId");
+CREATE INDEX "BookingAudit_actorId_idx" ON "BookingAudit"("actorId");
-- CreateIndex
-CREATE INDEX "BookingAudit_bookingUid_idx" ON "public"."BookingAudit"("bookingUid");
+CREATE INDEX "BookingAudit_bookingUid_idx" ON "BookingAudit"("bookingUid");
-- CreateIndex
-CREATE INDEX "BookingAudit_timestamp_idx" ON "public"."BookingAudit"("timestamp");
+CREATE INDEX "BookingAudit_timestamp_idx" ON "BookingAudit"("timestamp");
-- AddForeignKey
-ALTER TABLE "public"."BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "public"."AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE "BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql b/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql
index b0d7c98fe9f..6c026928839 100644
--- a/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql
+++ b/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql
@@ -1,5 +1,5 @@
-- AlterTable
-ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "disableAttendeeAwaitingPaymentEmail" BOOLEAN NOT NULL DEFAULT false,
+ALTER TABLE "OrganizationSettings" ADD COLUMN "disableAttendeeAwaitingPaymentEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeCancellationEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeConfirmationEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeLocationChangeEmail" BOOLEAN NOT NULL DEFAULT false,
diff --git a/packages/prisma/migrations/20251119124132_add_uuidv7_to_audit_actor/migration.sql b/packages/prisma/migrations/20251119124132_add_uuidv7_to_audit_actor/migration.sql
index 7ed7084b8b5..ee9e55dc44f 100644
--- a/packages/prisma/migrations/20251119124132_add_uuidv7_to_audit_actor/migration.sql
+++ b/packages/prisma/migrations/20251119124132_add_uuidv7_to_audit_actor/migration.sql
@@ -7,14 +7,14 @@
*/
-ALTER TABLE "public"."BookingAudit" DROP CONSTRAINT "BookingAudit_actorId_fkey";
+ALTER TABLE "BookingAudit" DROP CONSTRAINT "BookingAudit_actorId_fkey";
-ALTER TABLE "public"."BookingAudit" ALTER COLUMN "actorId" TYPE UUID USING "actorId"::UUID;
+ALTER TABLE "BookingAudit" ALTER COLUMN "actorId" TYPE UUID USING "actorId"::UUID;
-ALTER TABLE "public"."AuditActor" DROP CONSTRAINT "AuditActor_pkey";
+ALTER TABLE "AuditActor" DROP CONSTRAINT "AuditActor_pkey";
-ALTER TABLE "public"."AuditActor" ALTER COLUMN "id" TYPE UUID USING "id"::UUID;
+ALTER TABLE "AuditActor" ALTER COLUMN "id" TYPE UUID USING "id"::UUID;
-ALTER TABLE "public"."AuditActor" ADD CONSTRAINT "AuditActor_pkey" PRIMARY KEY ("id");
+ALTER TABLE "AuditActor" ADD CONSTRAINT "AuditActor_pkey" PRIMARY KEY ("id");
-ALTER TABLE "public"."BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "public"."AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE "BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20251121114503_add_pkce_oauth_client_type/migration.sql b/packages/prisma/migrations/20251121114503_add_pkce_oauth_client_type/migration.sql
index 5d5835e8278..9bc363f95ab 100644
--- a/packages/prisma/migrations/20251121114503_add_pkce_oauth_client_type/migration.sql
+++ b/packages/prisma/migrations/20251121114503_add_pkce_oauth_client_type/migration.sql
@@ -1,10 +1,10 @@
-- CreateEnum
-CREATE TYPE "public"."OAuthClientType" AS ENUM ('confidential', 'public');
+CREATE TYPE "OAuthClientType" AS ENUM ('confidential', 'public');
-- AlterTable
-ALTER TABLE "public"."AccessCode" ADD COLUMN "codeChallenge" TEXT,
+ALTER TABLE "AccessCode" ADD COLUMN "codeChallenge" TEXT,
ADD COLUMN "codeChallengeMethod" TEXT DEFAULT 'S256';
-- AlterTable
-ALTER TABLE "public"."OAuthClient" ADD COLUMN "clientType" "public"."OAuthClientType" NOT NULL DEFAULT 'confidential',
+ALTER TABLE "OAuthClient" ADD COLUMN "clientType" "OAuthClientType" NOT NULL DEFAULT 'confidential',
ALTER COLUMN "clientSecret" DROP NOT NULL;
diff --git a/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql b/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql
index 9f7154b793e..388d7e5f433 100644
--- a/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql
+++ b/packages/prisma/migrations/20251127102536_add_from_reschdule_index_to_booking/migration.sql
@@ -1,2 +1,2 @@
-- CreateIndex
-CREATE INDEX "Booking_fromReschedule_idx" ON "public"."Booking"("fromReschedule");
+CREATE INDEX "Booking_fromReschedule_idx" ON "Booking"("fromReschedule");
diff --git a/packages/prisma/migrations/20251129125459_add_show_note_publicly_to_ooo/migration.sql b/packages/prisma/migrations/20251129125459_add_show_note_publicly_to_ooo/migration.sql
index ace6302087e..3b1ec9061cb 100644
--- a/packages/prisma/migrations/20251129125459_add_show_note_publicly_to_ooo/migration.sql
+++ b/packages/prisma/migrations/20251129125459_add_show_note_publicly_to_ooo/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."OutOfOfficeEntry" ADD COLUMN "showNotePublicly" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "OutOfOfficeEntry" ADD COLUMN "showNotePublicly" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql b/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql
index 273175dbde6..d648879a46e 100644
--- a/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql
+++ b/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."EventType" ADD COLUMN "autoTranslateInstantMeetingTitleEnabled" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "EventType" ADD COLUMN "autoTranslateInstantMeetingTitleEnabled" BOOLEAN NOT NULL DEFAULT true;
diff --git a/packages/prisma/migrations/20251202181340_add_user_holiday_settings/migration.sql b/packages/prisma/migrations/20251202181340_add_user_holiday_settings/migration.sql
index 320cd03d133..62f11e2fcc4 100644
--- a/packages/prisma/migrations/20251202181340_add_user_holiday_settings/migration.sql
+++ b/packages/prisma/migrations/20251202181340_add_user_holiday_settings/migration.sql
@@ -1,5 +1,5 @@
-- CreateTable
-CREATE TABLE "public"."UserHolidaySettings" (
+CREATE TABLE "UserHolidaySettings" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"countryCode" TEXT,
@@ -11,7 +11,7 @@ CREATE TABLE "public"."UserHolidaySettings" (
);
-- CreateIndex
-CREATE UNIQUE INDEX "UserHolidaySettings_userId_key" ON "public"."UserHolidaySettings"("userId");
+CREATE UNIQUE INDEX "UserHolidaySettings_userId_key" ON "UserHolidaySettings"("userId");
-- AddForeignKey
-ALTER TABLE "public"."UserHolidaySettings" ADD CONSTRAINT "UserHolidaySettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "UserHolidaySettings" ADD CONSTRAINT "UserHolidaySettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql b/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql
index e27514e06e1..beb3d8fbfcf 100644
--- a/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql
+++ b/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."EventType" ADD COLUMN "minimumRescheduleNotice" INTEGER;
+ALTER TABLE "EventType" ADD COLUMN "minimumRescheduleNotice" INTEGER;
diff --git a/packages/prisma/migrations/20251205111359_add_is_trust_to_o_auth_client/migration.sql b/packages/prisma/migrations/20251205111359_add_is_trust_to_o_auth_client/migration.sql
index 29d712b54b7..48f6eae65ad 100644
--- a/packages/prisma/migrations/20251205111359_add_is_trust_to_o_auth_client/migration.sql
+++ b/packages/prisma/migrations/20251205111359_add_is_trust_to_o_auth_client/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."OAuthClient" ADD COLUMN "isTrusted" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "OAuthClient" ADD COLUMN "isTrusted" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql b/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql
index 1878c83c58c..a71b901f26b 100644
--- a/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql
+++ b/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."EventType" ADD COLUMN "rrHostSubsetEnabled" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "EventType" ADD COLUMN "rrHostSubsetEnabled" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/prisma/migrations/20251208035931_watchlist_audit_on_delete_set_null/migration.sql b/packages/prisma/migrations/20251208035931_watchlist_audit_on_delete_set_null/migration.sql
index b034213318f..6b87650928e 100644
--- a/packages/prisma/migrations/20251208035931_watchlist_audit_on_delete_set_null/migration.sql
+++ b/packages/prisma/migrations/20251208035931_watchlist_audit_on_delete_set_null/migration.sql
@@ -1,8 +1,8 @@
-- DropForeignKey
-ALTER TABLE "public"."WatchlistAudit" DROP CONSTRAINT "WatchlistAudit_watchlistId_fkey";
+ALTER TABLE "WatchlistAudit" DROP CONSTRAINT "WatchlistAudit_watchlistId_fkey";
-- AlterTable
-ALTER TABLE "public"."WatchlistAudit" ALTER COLUMN "watchlistId" DROP NOT NULL;
+ALTER TABLE "WatchlistAudit" ALTER COLUMN "watchlistId" DROP NOT NULL;
-- AddForeignKey
-ALTER TABLE "public"."WatchlistAudit" ADD CONSTRAINT "WatchlistAudit_watchlistId_fkey" FOREIGN KEY ("watchlistId") REFERENCES "public"."Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "WatchlistAudit" ADD CONSTRAINT "WatchlistAudit_watchlistId_fkey" FOREIGN KEY ("watchlistId") REFERENCES "Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql b/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql
index bde838d3d07..3b4179f86ae 100644
--- a/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql
+++ b/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql
@@ -1,9 +1,9 @@
-- AlterTable: Add enabled column with DEFAULT true so existing rows get enabled=true
-ALTER TABLE "public"."TeamFeatures" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "TeamFeatures" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable: Add enabled column with DEFAULT true so existing rows get enabled=true
-ALTER TABLE "public"."UserFeatures" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "UserFeatures" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
-- Remove the DEFAULT constraint so new rows must explicitly set enabled
-ALTER TABLE "public"."TeamFeatures" ALTER COLUMN "enabled" DROP DEFAULT;
-ALTER TABLE "public"."UserFeatures" ALTER COLUMN "enabled" DROP DEFAULT;
+ALTER TABLE "TeamFeatures" ALTER COLUMN "enabled" DROP DEFAULT;
+ALTER TABLE "UserFeatures" ALTER COLUMN "enabled" DROP DEFAULT;
diff --git a/packages/prisma/migrations/20251216074521_enhance_booking_audit_schema/migration.sql b/packages/prisma/migrations/20251216074521_enhance_booking_audit_schema/migration.sql
index 8c23c4ed58b..0bfa7cf546d 100644
--- a/packages/prisma/migrations/20251216074521_enhance_booking_audit_schema/migration.sql
+++ b/packages/prisma/migrations/20251216074521_enhance_booking_audit_schema/migration.sql
@@ -6,10 +6,10 @@
*/
-- CreateEnum
-CREATE TYPE "public"."BookingAuditSource" AS ENUM ('api_v1', 'api_v2', 'webapp', 'webhook', 'unknown');
+CREATE TYPE "BookingAuditSource" AS ENUM ('api_v1', 'api_v2', 'webapp', 'webhook', 'unknown');
-- AlterEnum
-ALTER TYPE "public"."AuditActorType" ADD VALUE 'app';
+ALTER TYPE "AuditActorType" ADD VALUE 'app';
-- AlterEnum
-- This migration adds more than one value to an enum.
@@ -19,12 +19,12 @@ ALTER TYPE "public"."AuditActorType" ADD VALUE 'app';
-- the enum.
-ALTER TYPE "public"."BookingAuditAction" ADD VALUE 'seat_booked';
-ALTER TYPE "public"."BookingAuditAction" ADD VALUE 'seat_rescheduled';
+ALTER TYPE "BookingAuditAction" ADD VALUE 'seat_booked';
+ALTER TYPE "BookingAuditAction" ADD VALUE 'seat_rescheduled';
-- AlterTable
-ALTER TABLE "public"."BookingAudit" ADD COLUMN "operationId" TEXT NOT NULL,
-ADD COLUMN "source" "public"."BookingAuditSource" NOT NULL;
+ALTER TABLE "BookingAudit" ADD COLUMN "operationId" TEXT NOT NULL,
+ADD COLUMN "source" "BookingAuditSource" NOT NULL;
-- CreateIndex
-CREATE INDEX "BookingAudit_operationId_idx" ON "public"."BookingAudit"("operationId");
+CREATE INDEX "BookingAudit_operationId_idx" ON "BookingAudit"("operationId");
diff --git a/packages/prisma/migrations/20251217090304_enhance_audit_schema/migration.sql b/packages/prisma/migrations/20251217090304_enhance_audit_schema/migration.sql
index 8aa9ff879dd..05c51abc1b1 100644
--- a/packages/prisma/migrations/20251217090304_enhance_audit_schema/migration.sql
+++ b/packages/prisma/migrations/20251217090304_enhance_audit_schema/migration.sql
@@ -5,10 +5,10 @@
*/
-- AlterTable
-ALTER TABLE "public"."AuditActor" ADD COLUMN "credentialId" INTEGER;
+ALTER TABLE "AuditActor" ADD COLUMN "credentialId" INTEGER;
-- CreateIndex
-CREATE INDEX "AuditActor_credentialId_idx" ON "public"."AuditActor"("credentialId");
+CREATE INDEX "AuditActor_credentialId_idx" ON "AuditActor"("credentialId");
-- CreateIndex
-CREATE UNIQUE INDEX "AuditActor_credentialId_key" ON "public"."AuditActor"("credentialId");
+CREATE UNIQUE INDEX "AuditActor_credentialId_key" ON "AuditActor"("credentialId");
diff --git a/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql b/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql
index 12fe20966ea..cb02fc0b87f 100644
--- a/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql
+++ b/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql
@@ -1,5 +1,5 @@
-- AlterTable
-ALTER TABLE "public"."Team" ADD COLUMN "autoOptInFeatures" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "Team" ADD COLUMN "autoOptInFeatures" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
-ALTER TABLE "public"."users" ADD COLUMN "autoOptInFeatures" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "users" ADD COLUMN "autoOptInFeatures" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql b/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql
index 5f8efdbcf39..6fb69162586 100644
--- a/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql
+++ b/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql
@@ -1,15 +1,15 @@
-- CreateEnum
-CREATE TYPE "public"."SystemReportStatus" AS ENUM ('PENDING', 'BLOCKED', 'DISMISSED');
+CREATE TYPE "SystemReportStatus" AS ENUM ('PENDING', 'BLOCKED', 'DISMISSED');
-- AlterTable
-ALTER TABLE "public"."BookingReport" ADD COLUMN "globalWatchlistId" UUID,
-ADD COLUMN "systemStatus" "public"."SystemReportStatus" NOT NULL DEFAULT 'PENDING';
+ALTER TABLE "BookingReport" ADD COLUMN "globalWatchlistId" UUID,
+ADD COLUMN "systemStatus" "SystemReportStatus" NOT NULL DEFAULT 'PENDING';
-- CreateIndex
-CREATE INDEX "BookingReport_globalWatchlistId_idx" ON "public"."BookingReport"("globalWatchlistId");
+CREATE INDEX "BookingReport_globalWatchlistId_idx" ON "BookingReport"("globalWatchlistId");
-- CreateIndex
-CREATE INDEX "BookingReport_systemStatus_idx" ON "public"."BookingReport"("systemStatus");
+CREATE INDEX "BookingReport_systemStatus_idx" ON "BookingReport"("systemStatus");
-- AddForeignKey
-ALTER TABLE "public"."BookingReport" ADD CONSTRAINT "BookingReport_globalWatchlistId_fkey" FOREIGN KEY ("globalWatchlistId") REFERENCES "public"."Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_globalWatchlistId_fkey" FOREIGN KEY ("globalWatchlistId") REFERENCES "Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20251220034814_add_custom_calendar_reminder/migration.sql b/packages/prisma/migrations/20251220034814_add_custom_calendar_reminder/migration.sql
index 0d1b7624652..ad6a40e5930 100644
--- a/packages/prisma/migrations/20251220034814_add_custom_calendar_reminder/migration.sql
+++ b/packages/prisma/migrations/20251220034814_add_custom_calendar_reminder/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."DestinationCalendar" ADD COLUMN "customCalendarReminder" INTEGER;
+ALTER TABLE "DestinationCalendar" ADD COLUMN "customCalendarReminder" INTEGER;
diff --git a/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql b/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql
index 4693eae322b..6f462b21bf0 100644
--- a/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql
+++ b/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."BookingAudit" ADD COLUMN "context" JSONB;
+ALTER TABLE "BookingAudit" ADD COLUMN "context" JSONB;
diff --git a/packages/prisma/migrations/20260105071846_update_watchlist_index_with_is_global/migration.sql b/packages/prisma/migrations/20260105071846_update_watchlist_index_with_is_global/migration.sql
index 401fc9a633c..d4f4ecbca33 100644
--- a/packages/prisma/migrations/20260105071846_update_watchlist_index_with_is_global/migration.sql
+++ b/packages/prisma/migrations/20260105071846_update_watchlist_index_with_is_global/migration.sql
@@ -1,5 +1,5 @@
-- DropIndex
-DROP INDEX "public"."Watchlist_type_value_organizationId_action_idx";
+DROP INDEX "Watchlist_type_value_organizationId_action_idx";
-- CreateIndex
-CREATE INDEX "Watchlist_isGlobal_action_organizationId_type_value_idx" ON "public"."Watchlist"("isGlobal", "action", "organizationId", "type", "value");
+CREATE INDEX "Watchlist_isGlobal_action_organizationId_type_value_idx" ON "Watchlist"("isGlobal", "action", "organizationId", "type", "value");
diff --git a/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql b/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql
index 76705f1d6d4..3171cf97a7c 100644
--- a/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql
+++ b/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql
@@ -1,24 +1,24 @@
-- CreateEnum
-CREATE TYPE "public"."SeatChangeType" AS ENUM ('ADDITION', 'REMOVAL');
+CREATE TYPE "SeatChangeType" AS ENUM ('ADDITION', 'REMOVAL');
-- CreateEnum
-CREATE TYPE "public"."ProrationStatus" AS ENUM ('PENDING', 'INVOICE_CREATED', 'CHARGED', 'FAILED', 'CANCELLED');
+CREATE TYPE "ProrationStatus" AS ENUM ('PENDING', 'INVOICE_CREATED', 'CHARGED', 'FAILED', 'CANCELLED');
-- AlterTable
-ALTER TABLE "public"."OrganizationBilling" ADD COLUMN "billingPeriod" "public"."BillingPeriod",
+ALTER TABLE "OrganizationBilling" ADD COLUMN "billingPeriod" "BillingPeriod",
ADD COLUMN "pricePerSeat" INTEGER,
ADD COLUMN "paidSeats" INTEGER;
-- AlterTable
-ALTER TABLE "public"."TeamBilling" ADD COLUMN "billingPeriod" "public"."BillingPeriod",
+ALTER TABLE "TeamBilling" ADD COLUMN "billingPeriod" "BillingPeriod",
ADD COLUMN "pricePerSeat" INTEGER,
ADD COLUMN "paidSeats" INTEGER;
-- CreateTable
-CREATE TABLE "public"."SeatChangeLog" (
+CREATE TABLE "SeatChangeLog" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
- "changeType" "public"."SeatChangeType" NOT NULL,
+ "changeType" "SeatChangeType" NOT NULL,
"seatCount" INTEGER NOT NULL,
"userId" INTEGER,
"triggeredBy" INTEGER,
@@ -34,7 +34,7 @@ CREATE TABLE "public"."SeatChangeLog" (
);
-- CreateTable
-CREATE TABLE "public"."MonthlyProration" (
+CREATE TABLE "MonthlyProration" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"monthKey" TEXT NOT NULL,
@@ -55,7 +55,7 @@ CREATE TABLE "public"."MonthlyProration" (
"proratedAmount" INTEGER NOT NULL,
"invoiceItemId" TEXT,
"invoiceId" TEXT,
- "status" "public"."ProrationStatus" NOT NULL DEFAULT 'PENDING',
+ "status" "ProrationStatus" NOT NULL DEFAULT 'PENDING',
"chargedAt" TIMESTAMP(3),
"failedAt" TIMESTAMP(3),
"failureReason" TEXT,
@@ -70,48 +70,48 @@ CREATE TABLE "public"."MonthlyProration" (
);
-- CreateIndex
-CREATE INDEX "SeatChangeLog_teamId_monthKey_idx" ON "public"."SeatChangeLog"("teamId", "monthKey");
+CREATE INDEX "SeatChangeLog_teamId_monthKey_idx" ON "SeatChangeLog"("teamId", "monthKey");
-- CreateIndex
-CREATE INDEX "SeatChangeLog_teamId_processedInProrationId_idx" ON "public"."SeatChangeLog"("teamId", "processedInProrationId");
+CREATE INDEX "SeatChangeLog_teamId_processedInProrationId_idx" ON "SeatChangeLog"("teamId", "processedInProrationId");
-- CreateIndex
-CREATE INDEX "SeatChangeLog_monthKey_idx" ON "public"."SeatChangeLog"("monthKey");
+CREATE INDEX "SeatChangeLog_monthKey_idx" ON "SeatChangeLog"("monthKey");
-- CreateIndex
-CREATE INDEX "MonthlyProration_monthKey_status_idx" ON "public"."MonthlyProration"("monthKey", "status");
+CREATE INDEX "MonthlyProration_monthKey_status_idx" ON "MonthlyProration"("monthKey", "status");
-- CreateIndex
-CREATE INDEX "MonthlyProration_status_idx" ON "public"."MonthlyProration"("status");
+CREATE INDEX "MonthlyProration_status_idx" ON "MonthlyProration"("status");
-- CreateIndex
-CREATE INDEX "MonthlyProration_teamId_idx" ON "public"."MonthlyProration"("teamId");
+CREATE INDEX "MonthlyProration_teamId_idx" ON "MonthlyProration"("teamId");
-- CreateIndex
-CREATE UNIQUE INDEX "MonthlyProration_teamId_monthKey_key" ON "public"."MonthlyProration"("teamId", "monthKey");
+CREATE UNIQUE INDEX "MonthlyProration_teamId_monthKey_key" ON "MonthlyProration"("teamId", "monthKey");
-- AddForeignKey
-ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_processedInProrationId_fkey" FOREIGN KEY ("processedInProrationId") REFERENCES "public"."MonthlyProration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_processedInProrationId_fkey" FOREIGN KEY ("processedInProrationId") REFERENCES "MonthlyProration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_teamBillingId_fkey" FOREIGN KEY ("teamBillingId") REFERENCES "public"."TeamBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_teamBillingId_fkey" FOREIGN KEY ("teamBillingId") REFERENCES "TeamBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_organizationBillingId_fkey" FOREIGN KEY ("organizationBillingId") REFERENCES "public"."OrganizationBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_organizationBillingId_fkey" FOREIGN KEY ("organizationBillingId") REFERENCES "OrganizationBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."MonthlyProration" ADD CONSTRAINT "MonthlyProration_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "MonthlyProration" ADD CONSTRAINT "MonthlyProration_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."MonthlyProration" ADD CONSTRAINT "MonthlyProration_teamBillingId_fkey" FOREIGN KEY ("teamBillingId") REFERENCES "public"."TeamBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "MonthlyProration" ADD CONSTRAINT "MonthlyProration_teamBillingId_fkey" FOREIGN KEY ("teamBillingId") REFERENCES "TeamBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."MonthlyProration" ADD CONSTRAINT "MonthlyProration_organizationBillingId_fkey" FOREIGN KEY ("organizationBillingId") REFERENCES "public"."OrganizationBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "MonthlyProration" ADD CONSTRAINT "MonthlyProration_organizationBillingId_fkey" FOREIGN KEY ("organizationBillingId") REFERENCES "OrganizationBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Insert feature flag (disabled by default)
-INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt")
+INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt")
VALUES ('monthly-proration', false, 'Monthly aggregated seat proration for annual plans', 'RELEASE', false, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;
diff --git a/packages/prisma/migrations/20260107093019_add_magic_link_source/migration.sql b/packages/prisma/migrations/20260107093019_add_magic_link_source/migration.sql
index e32e218fd1f..2d685859d92 100644
--- a/packages/prisma/migrations/20260107093019_add_magic_link_source/migration.sql
+++ b/packages/prisma/migrations/20260107093019_add_magic_link_source/migration.sql
@@ -1,2 +1,2 @@
-- AlterEnum
-ALTER TYPE "public"."BookingAuditSource" ADD VALUE 'magic_link';
+ALTER TYPE "BookingAuditSource" ADD VALUE 'magic_link';
diff --git a/packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql b/packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql
index e6b6af94053..9b8a0085de0 100644
--- a/packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql
+++ b/packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql
@@ -1,5 +1,5 @@
-- CreateTable
-CREATE TABLE "public"."IntegrationAttributeSync" (
+CREATE TABLE "IntegrationAttributeSync" (
"id" TEXT NOT NULL,
"organizationId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
@@ -13,7 +13,7 @@ CREATE TABLE "public"."IntegrationAttributeSync" (
);
-- CreateTable
-CREATE TABLE "public"."AttributeSyncRule" (
+CREATE TABLE "AttributeSyncRule" (
"id" TEXT NOT NULL,
"integrationAttributeSyncId" TEXT NOT NULL,
"rule" JSONB NOT NULL,
@@ -24,7 +24,7 @@ CREATE TABLE "public"."AttributeSyncRule" (
);
-- CreateTable
-CREATE TABLE "public"."AttributeSyncFieldMapping" (
+CREATE TABLE "AttributeSyncFieldMapping" (
"id" TEXT NOT NULL,
"integrationFieldName" TEXT NOT NULL,
"attributeId" TEXT NOT NULL,
@@ -37,25 +37,25 @@ CREATE TABLE "public"."AttributeSyncFieldMapping" (
);
-- CreateIndex
-CREATE INDEX "IntegrationAttributeSync_organizationId_idx" ON "public"."IntegrationAttributeSync"("organizationId");
+CREATE INDEX "IntegrationAttributeSync_organizationId_idx" ON "IntegrationAttributeSync"("organizationId");
-- CreateIndex
-CREATE UNIQUE INDEX "AttributeSyncRule_integrationAttributeSyncId_key" ON "public"."AttributeSyncRule"("integrationAttributeSyncId");
+CREATE UNIQUE INDEX "AttributeSyncRule_integrationAttributeSyncId_key" ON "AttributeSyncRule"("integrationAttributeSyncId");
-- CreateIndex
-CREATE INDEX "AttributeSyncFieldMapping_integrationAttributeSyncId_idx" ON "public"."AttributeSyncFieldMapping"("integrationAttributeSyncId");
+CREATE INDEX "AttributeSyncFieldMapping_integrationAttributeSyncId_idx" ON "AttributeSyncFieldMapping"("integrationAttributeSyncId");
-- AddForeignKey
-ALTER TABLE "public"."IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "public"."Credential"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "Credential"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."AttributeSyncRule" ADD CONSTRAINT "AttributeSyncRule_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "public"."IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "AttributeSyncRule" ADD CONSTRAINT "AttributeSyncRule_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "public"."Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "public"."IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260113140724_add_missing_integrationattributesync_and_credential_index/migration.sql b/packages/prisma/migrations/20260113140724_add_missing_integrationattributesync_and_credential_index/migration.sql
index 92245992239..f11ae149714 100644
--- a/packages/prisma/migrations/20260113140724_add_missing_integrationattributesync_and_credential_index/migration.sql
+++ b/packages/prisma/migrations/20260113140724_add_missing_integrationattributesync_and_credential_index/migration.sql
@@ -1,2 +1,2 @@
-- CreateIndex
-CREATE INDEX "IntegrationAttributeSync_credentialId_idx" ON "public"."IntegrationAttributeSync"("credentialId");
+CREATE INDEX "IntegrationAttributeSync_credentialId_idx" ON "IntegrationAttributeSync"("credentialId");
diff --git a/packages/prisma/migrations/20260114154054_add_oauth_client_properties/migration.sql b/packages/prisma/migrations/20260114154054_add_oauth_client_properties/migration.sql
index 20d0dd409a1..0b295af0a91 100644
--- a/packages/prisma/migrations/20260114154054_add_oauth_client_properties/migration.sql
+++ b/packages/prisma/migrations/20260114154054_add_oauth_client_properties/migration.sql
@@ -1,16 +1,16 @@
-- CreateEnum
-CREATE TYPE "public"."OAuthClientStatus" AS ENUM ('pending', 'approved', 'rejected');
+CREATE TYPE "OAuthClientStatus" AS ENUM ('pending', 'approved', 'rejected');
-- AlterTable
-ALTER TABLE "public"."OAuthClient" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ALTER TABLE "OAuthClient" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "purpose" TEXT,
ADD COLUMN "rejectionReason" TEXT,
-ADD COLUMN "status" "public"."OAuthClientStatus" NOT NULL DEFAULT 'approved',
+ADD COLUMN "status" "OAuthClientStatus" NOT NULL DEFAULT 'approved',
ADD COLUMN "userId" INTEGER,
ADD COLUMN "websiteUrl" TEXT;
-- CreateIndex
-CREATE INDEX "OAuthClient_userId_idx" ON "public"."OAuthClient"("userId");
+CREATE INDEX "OAuthClient_userId_idx" ON "OAuthClient"("userId");
-- AddForeignKey
-ALTER TABLE "public"."OAuthClient" ADD CONSTRAINT "OAuthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260115111819_add_cancellation_reason_require/migration.sql b/packages/prisma/migrations/20260115111819_add_cancellation_reason_require/migration.sql
index 5caebc6a86c..a1bbb670f54 100644
--- a/packages/prisma/migrations/20260115111819_add_cancellation_reason_require/migration.sql
+++ b/packages/prisma/migrations/20260115111819_add_cancellation_reason_require/migration.sql
@@ -1,5 +1,5 @@
-- CreateEnum
-CREATE TYPE "public"."CancellationReasonRequirement" AS ENUM ('MANDATORY_BOTH', 'MANDATORY_HOST_ONLY', 'MANDATORY_ATTENDEE_ONLY', 'OPTIONAL_BOTH');
+CREATE TYPE "CancellationReasonRequirement" AS ENUM ('MANDATORY_BOTH', 'MANDATORY_HOST_ONLY', 'MANDATORY_ATTENDEE_ONLY', 'OPTIONAL_BOTH');
-- AlterTable
-ALTER TABLE "public"."EventType" ADD COLUMN "requiresCancellationReason" "public"."CancellationReasonRequirement" DEFAULT 'MANDATORY_HOST_ONLY';
+ALTER TABLE "EventType" ADD COLUMN "requiresCancellationReason" "CancellationReasonRequirement" DEFAULT 'MANDATORY_HOST_ONLY';
diff --git a/packages/prisma/migrations/20260115155453_add_integration_sync_constraint_on_mapping_and_attribute/migration.sql b/packages/prisma/migrations/20260115155453_add_integration_sync_constraint_on_mapping_and_attribute/migration.sql
index 8dd118ec173..9da843aae37 100644
--- a/packages/prisma/migrations/20260115155453_add_integration_sync_constraint_on_mapping_and_attribute/migration.sql
+++ b/packages/prisma/migrations/20260115155453_add_integration_sync_constraint_on_mapping_and_attribute/migration.sql
@@ -5,4 +5,4 @@
*/
-- CreateIndex
-CREATE UNIQUE INDEX IF NOT EXISTS "AttributeSyncFieldMapping_integrationAttributeSyncId_attrib_key" ON "public"."AttributeSyncFieldMapping"("integrationAttributeSyncId", "attributeId");
+CREATE UNIQUE INDEX IF NOT EXISTS "AttributeSyncFieldMapping_integrationAttributeSyncId_attrib_key" ON "AttributeSyncFieldMapping"("integrationAttributeSyncId", "attributeId");
diff --git a/packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql b/packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql
index 55c5b4d79a6..2bd462e4b1c 100644
--- a/packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql
+++ b/packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql
@@ -1,8 +1,8 @@
-- AlterTable
-ALTER TABLE "public"."EventType" ADD COLUMN "enablePerHostLocations" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "EventType" ADD COLUMN "enablePerHostLocations" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
-CREATE TABLE "public"."HostLocation" (
+CREATE TABLE "HostLocation" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"eventTypeId" INTEGER NOT NULL,
@@ -18,16 +18,16 @@ CREATE TABLE "public"."HostLocation" (
);
-- CreateIndex
-CREATE INDEX "HostLocation_credentialId_idx" ON "public"."HostLocation"("credentialId");
+CREATE INDEX "HostLocation_credentialId_idx" ON "HostLocation"("credentialId");
-- CreateIndex
-CREATE INDEX "HostLocation_eventTypeId_idx" ON "public"."HostLocation"("eventTypeId");
+CREATE INDEX "HostLocation_eventTypeId_idx" ON "HostLocation"("eventTypeId");
-- CreateIndex
-CREATE UNIQUE INDEX "HostLocation_userId_eventTypeId_key" ON "public"."HostLocation"("userId", "eventTypeId");
+CREATE UNIQUE INDEX "HostLocation_userId_eventTypeId_key" ON "HostLocation"("userId", "eventTypeId");
-- AddForeignKey
-ALTER TABLE "public"."HostLocation" ADD CONSTRAINT "HostLocation_userId_eventTypeId_fkey" FOREIGN KEY ("userId", "eventTypeId") REFERENCES "public"."Host"("userId", "eventTypeId") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "HostLocation" ADD CONSTRAINT "HostLocation_userId_eventTypeId_fkey" FOREIGN KEY ("userId", "eventTypeId") REFERENCES "Host"("userId", "eventTypeId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."HostLocation" ADD CONSTRAINT "HostLocation_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "public"."Credential"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "HostLocation" ADD CONSTRAINT "HostLocation_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "Credential"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260119113000_add_system_source/migration.sql b/packages/prisma/migrations/20260119113000_add_system_source/migration.sql
index f36210d8fc3..7760306c146 100644
--- a/packages/prisma/migrations/20260119113000_add_system_source/migration.sql
+++ b/packages/prisma/migrations/20260119113000_add_system_source/migration.sql
@@ -1,2 +1,2 @@
-- AlterEnum
-ALTER TYPE "public"."BookingAuditSource" ADD VALUE 'system';
+ALTER TYPE "BookingAuditSource" ADD VALUE 'system';
diff --git a/packages/prisma/migrations/20260119120000_add_seat_change_log_operation_id/migration.sql b/packages/prisma/migrations/20260119120000_add_seat_change_log_operation_id/migration.sql
index dd9b4707616..5a6065c58be 100644
--- a/packages/prisma/migrations/20260119120000_add_seat_change_log_operation_id/migration.sql
+++ b/packages/prisma/migrations/20260119120000_add_seat_change_log_operation_id/migration.sql
@@ -5,7 +5,7 @@
*/
-- AlterTable
-ALTER TABLE "public"."SeatChangeLog" ADD COLUMN "operationId" TEXT;
+ALTER TABLE "SeatChangeLog" ADD COLUMN "operationId" TEXT;
-- CreateIndex
-CREATE UNIQUE INDEX "SeatChangeLog_teamId_operationId_key" ON "public"."SeatChangeLog"("teamId", "operationId");
+CREATE UNIQUE INDEX "SeatChangeLog_teamId_operationId_key" ON "SeatChangeLog"("teamId", "operationId");
diff --git a/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql b/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql
index eb81b061626..03ef509426d 100644
--- a/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql
+++ b/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql
@@ -1,4 +1,4 @@
-- AlterTable
-ALTER TABLE "public"."SelectedCalendar"
+ALTER TABLE "SelectedCalendar"
ADD COLUMN "syncSubscribedErrorAt" TIMESTAMP(3),
ADD COLUMN "syncSubscribedErrorCount" INTEGER NOT NULL DEFAULT 0;
diff --git a/packages/prisma/migrations/20260121145242_add_workflow_step_translation/migration.sql b/packages/prisma/migrations/20260121145242_add_workflow_step_translation/migration.sql
index 8054d1d74e2..cc4b67206a9 100644
--- a/packages/prisma/migrations/20260121145242_add_workflow_step_translation/migration.sql
+++ b/packages/prisma/migrations/20260121145242_add_workflow_step_translation/migration.sql
@@ -1,15 +1,15 @@
-- CreateEnum
-CREATE TYPE "public"."WorkflowStepAutoTranslatedField" AS ENUM ('REMINDER_BODY', 'EMAIL_SUBJECT');
+CREATE TYPE "WorkflowStepAutoTranslatedField" AS ENUM ('REMINDER_BODY', 'EMAIL_SUBJECT');
-- AlterTable
-ALTER TABLE "public"."WorkflowStep" ADD COLUMN "autoTranslateEnabled" BOOLEAN NOT NULL DEFAULT false,
+ALTER TABLE "WorkflowStep" ADD COLUMN "autoTranslateEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sourceLocale" TEXT;
-- CreateTable
-CREATE TABLE "public"."WorkflowStepTranslation" (
+CREATE TABLE "WorkflowStepTranslation" (
"uid" TEXT NOT NULL,
"workflowStepId" INTEGER NOT NULL,
- "field" "public"."WorkflowStepAutoTranslatedField" NOT NULL,
+ "field" "WorkflowStepAutoTranslatedField" NOT NULL,
"sourceLocale" TEXT NOT NULL,
"targetLocale" TEXT NOT NULL,
"translatedText" TEXT NOT NULL,
@@ -20,7 +20,7 @@ CREATE TABLE "public"."WorkflowStepTranslation" (
);
-- CreateIndex
-CREATE UNIQUE INDEX "WorkflowStepTranslation_workflowStepId_field_targetLocale_key" ON "public"."WorkflowStepTranslation"("workflowStepId", "field", "targetLocale");
+CREATE UNIQUE INDEX "WorkflowStepTranslation_workflowStepId_field_targetLocale_key" ON "WorkflowStepTranslation"("workflowStepId", "field", "targetLocale");
-- AddForeignKey
-ALTER TABLE "public"."WorkflowStepTranslation" ADD CONSTRAINT "WorkflowStepTranslation_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "public"."WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "WorkflowStepTranslation" ADD CONSTRAINT "WorkflowStepTranslation_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260121191700_add_eventtype_parentid_teamid_index/migration.sql b/packages/prisma/migrations/20260121191700_add_eventtype_parentid_teamid_index/migration.sql
index dc443f0b554..ff62ddedc14 100644
--- a/packages/prisma/migrations/20260121191700_add_eventtype_parentid_teamid_index/migration.sql
+++ b/packages/prisma/migrations/20260121191700_add_eventtype_parentid_teamid_index/migration.sql
@@ -1,2 +1,2 @@
-- CreateIndex
-CREATE INDEX "EventType_parentId_teamId_idx" ON "public"."EventType"("parentId", "teamId");
+CREATE INDEX "EventType_parentId_teamId_idx" ON "EventType"("parentId", "teamId");
diff --git a/packages/prisma/migrations/20260122133147_add_wrong_assignment_webhook/migration.sql b/packages/prisma/migrations/20260122133147_add_wrong_assignment_webhook/migration.sql
index a26ee4f0589..fdca4bbccd7 100644
--- a/packages/prisma/migrations/20260122133147_add_wrong_assignment_webhook/migration.sql
+++ b/packages/prisma/migrations/20260122133147_add_wrong_assignment_webhook/migration.sql
@@ -1,2 +1,2 @@
-- AlterEnum
-ALTER TYPE "public"."WebhookTriggerEvents" ADD VALUE 'WRONG_ASSIGNMENT_REPORT';
+ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'WRONG_ASSIGNMENT_REPORT';
diff --git a/packages/prisma/migrations/20260122140703_add_credential_teamid_index/migration.sql b/packages/prisma/migrations/20260122140703_add_credential_teamid_index/migration.sql
index 590aadb3572..ba06921ba19 100644
--- a/packages/prisma/migrations/20260122140703_add_credential_teamid_index/migration.sql
+++ b/packages/prisma/migrations/20260122140703_add_credential_teamid_index/migration.sql
@@ -1,2 +1,2 @@
-- CreateIndex
-CREATE INDEX "Credential_teamId_idx" ON "public"."Credential"("teamId");
+CREATE INDEX "Credential_teamId_idx" ON "Credential"("teamId");
diff --git a/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql b/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql
index ad0eed45f56..79e39bd29cc 100644
--- a/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql
+++ b/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."Credential" ADD COLUMN "encryptedKey" TEXT;
+ALTER TABLE "Credential" ADD COLUMN "encryptedKey" TEXT;
diff --git a/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql b/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql
index bf1eb7b9375..d39c526ef94 100644
--- a/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql
+++ b/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql
@@ -1,5 +1,5 @@
-- CreateTable
-CREATE TABLE "public"."PendingRoutingTrace" (
+CREATE TABLE "PendingRoutingTrace" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"trace" JSONB NOT NULL,
@@ -10,7 +10,7 @@ CREATE TABLE "public"."PendingRoutingTrace" (
);
-- CreateTable
-CREATE TABLE "public"."RoutingTrace" (
+CREATE TABLE "RoutingTrace" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"trace" JSONB NOT NULL,
@@ -23,43 +23,43 @@ CREATE TABLE "public"."RoutingTrace" (
);
-- CreateIndex
-CREATE UNIQUE INDEX "PendingRoutingTrace_formResponseId_key" ON "public"."PendingRoutingTrace"("formResponseId");
+CREATE UNIQUE INDEX "PendingRoutingTrace_formResponseId_key" ON "PendingRoutingTrace"("formResponseId");
-- CreateIndex
-CREATE UNIQUE INDEX "PendingRoutingTrace_queuedFormResponseId_key" ON "public"."PendingRoutingTrace"("queuedFormResponseId");
+CREATE UNIQUE INDEX "PendingRoutingTrace_queuedFormResponseId_key" ON "PendingRoutingTrace"("queuedFormResponseId");
-- CreateIndex
-CREATE UNIQUE INDEX "RoutingTrace_formResponseId_key" ON "public"."RoutingTrace"("formResponseId");
+CREATE UNIQUE INDEX "RoutingTrace_formResponseId_key" ON "RoutingTrace"("formResponseId");
-- CreateIndex
-CREATE UNIQUE INDEX "RoutingTrace_queuedFormResponseId_key" ON "public"."RoutingTrace"("queuedFormResponseId");
+CREATE UNIQUE INDEX "RoutingTrace_queuedFormResponseId_key" ON "RoutingTrace"("queuedFormResponseId");
-- CreateIndex
-CREATE UNIQUE INDEX "RoutingTrace_bookingUid_key" ON "public"."RoutingTrace"("bookingUid");
+CREATE UNIQUE INDEX "RoutingTrace_bookingUid_key" ON "RoutingTrace"("bookingUid");
-- CreateIndex
-CREATE UNIQUE INDEX "RoutingTrace_assignmentReasonId_key" ON "public"."RoutingTrace"("assignmentReasonId");
+CREATE UNIQUE INDEX "RoutingTrace_assignmentReasonId_key" ON "RoutingTrace"("assignmentReasonId");
-- AddForeignKey
-ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "RoutingTrace" ADD CONSTRAINT "RoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "RoutingTrace" ADD CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "RoutingTrace" ADD CONSTRAINT "RoutingTrace_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_assignmentReasonId_fkey" FOREIGN KEY ("assignmentReasonId") REFERENCES "public"."AssignmentReason"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "RoutingTrace" ADD CONSTRAINT "RoutingTrace_assignmentReasonId_fkey" FOREIGN KEY ("assignmentReasonId") REFERENCES "AssignmentReason"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set
-ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
+ALTER TABLE "PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set
-ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
+ALTER TABLE "RoutingTrace" ADD CONSTRAINT "RoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
diff --git a/packages/prisma/migrations/20260127140951_add_invoice_url_to_proration/migration.sql b/packages/prisma/migrations/20260127140951_add_invoice_url_to_proration/migration.sql
index 3fb9f5e4ff5..cb60257737f 100644
--- a/packages/prisma/migrations/20260127140951_add_invoice_url_to_proration/migration.sql
+++ b/packages/prisma/migrations/20260127140951_add_invoice_url_to_proration/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."MonthlyProration" ADD COLUMN "invoiceUrl" TEXT;
+ALTER TABLE "MonthlyProration" ADD COLUMN "invoiceUrl" TEXT;
diff --git a/packages/prisma/migrations/20260128170309_add_secondary_email_index/migration.sql b/packages/prisma/migrations/20260128170309_add_secondary_email_index/migration.sql
index b011fffd6dd..385679f53ba 100644
--- a/packages/prisma/migrations/20260128170309_add_secondary_email_index/migration.sql
+++ b/packages/prisma/migrations/20260128170309_add_secondary_email_index/migration.sql
@@ -1,2 +1,2 @@
-- CreateIndex
-CREATE INDEX IF NOT EXISTS "SecondaryEmail_email_emailVerified_idx" ON "public"."SecondaryEmail"("email", "emailVerified");
+CREATE INDEX IF NOT EXISTS "SecondaryEmail_email_emailVerified_idx" ON "SecondaryEmail"("email", "emailVerified");
diff --git a/packages/prisma/migrations/20260129205827_add_wrong_assignment_report_table/migration.sql b/packages/prisma/migrations/20260129205827_add_wrong_assignment_report_table/migration.sql
index e1cb2ee462f..992dbb9ab0d 100644
--- a/packages/prisma/migrations/20260129205827_add_wrong_assignment_report_table/migration.sql
+++ b/packages/prisma/migrations/20260129205827_add_wrong_assignment_report_table/migration.sql
@@ -1,8 +1,8 @@
-- CreateEnum
-CREATE TYPE "public"."WrongAssignmentReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'RESOLVED', 'DISMISSED');
+CREATE TYPE "WrongAssignmentReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'RESOLVED', 'DISMISSED');
-- CreateTable
-CREATE TABLE "public"."WrongAssignmentReport" (
+CREATE TABLE "WrongAssignmentReport" (
"id" UUID NOT NULL,
"bookingUid" TEXT NOT NULL,
"reportedById" INTEGER,
@@ -10,7 +10,7 @@ CREATE TABLE "public"."WrongAssignmentReport" (
"additionalNotes" TEXT NOT NULL,
"teamId" INTEGER,
"routingFormId" TEXT,
- "status" "public"."WrongAssignmentReportStatus" NOT NULL DEFAULT 'PENDING',
+ "status" "WrongAssignmentReportStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -18,31 +18,31 @@ CREATE TABLE "public"."WrongAssignmentReport" (
);
-- CreateIndex
-CREATE UNIQUE INDEX "WrongAssignmentReport_bookingUid_key" ON "public"."WrongAssignmentReport"("bookingUid");
+CREATE UNIQUE INDEX "WrongAssignmentReport_bookingUid_key" ON "WrongAssignmentReport"("bookingUid");
-- CreateIndex
-CREATE INDEX "WrongAssignmentReport_reportedById_idx" ON "public"."WrongAssignmentReport"("reportedById");
+CREATE INDEX "WrongAssignmentReport_reportedById_idx" ON "WrongAssignmentReport"("reportedById");
-- CreateIndex
-CREATE INDEX "WrongAssignmentReport_teamId_idx" ON "public"."WrongAssignmentReport"("teamId");
+CREATE INDEX "WrongAssignmentReport_teamId_idx" ON "WrongAssignmentReport"("teamId");
-- CreateIndex
-CREATE INDEX "WrongAssignmentReport_routingFormId_idx" ON "public"."WrongAssignmentReport"("routingFormId");
+CREATE INDEX "WrongAssignmentReport_routingFormId_idx" ON "WrongAssignmentReport"("routingFormId");
-- CreateIndex
-CREATE INDEX "WrongAssignmentReport_status_idx" ON "public"."WrongAssignmentReport"("status");
+CREATE INDEX "WrongAssignmentReport_status_idx" ON "WrongAssignmentReport"("status");
-- CreateIndex
-CREATE INDEX "WrongAssignmentReport_createdAt_idx" ON "public"."WrongAssignmentReport"("createdAt");
+CREATE INDEX "WrongAssignmentReport_createdAt_idx" ON "WrongAssignmentReport"("createdAt");
-- AddForeignKey
-ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
-ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_routingFormId_fkey" FOREIGN KEY ("routingFormId") REFERENCES "public"."App_RoutingForms_Form"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_routingFormId_fkey" FOREIGN KEY ("routingFormId") REFERENCES "App_RoutingForms_Form"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql b/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql
index 6ff898f715c..7e91c7e9b95 100644
--- a/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql
+++ b/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql
@@ -1,9 +1,9 @@
-- AlterTable
-ALTER TABLE "public"."WrongAssignmentReport" ADD COLUMN "reviewedAt" TIMESTAMP(3),
+ALTER TABLE "WrongAssignmentReport" ADD COLUMN "reviewedAt" TIMESTAMP(3),
ADD COLUMN "reviewedById" INTEGER;
-- CreateIndex
-CREATE INDEX "WrongAssignmentReport_reviewedById_idx" ON "public"."WrongAssignmentReport"("reviewedById");
+CREATE INDEX "WrongAssignmentReport_reviewedById_idx" ON "WrongAssignmentReport"("reviewedById");
-- AddForeignKey
-ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql b/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql
index 5f8d2d62b14..e1182773644 100644
--- a/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql
+++ b/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql
@@ -6,6 +6,6 @@ ADD COLUMN "highWaterMarkPeriodStart" TIMESTAMP(3);
ALTER TABLE "OrganizationBilling" ADD COLUMN "highWaterMark" INTEGER,
ADD COLUMN "highWaterMarkPeriodStart" TIMESTAMP(3);
-INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt")
+INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt")
VALUES ('hwm-seating', false, 'High water mark seating for monthly billing - charges for peak seats used during billing period', 'RELEASE', false, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;
diff --git a/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql b/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql
index 57ba25c19b5..a2b64ddf1fc 100644
--- a/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql
+++ b/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "public"."EventType" ADD COLUMN "redirectUrlOnNoRoutingFormResponse" TEXT;
+ALTER TABLE "EventType" ADD COLUMN "redirectUrlOnNoRoutingFormResponse" TEXT;
diff --git a/packages/prisma/migrations/20260305043434_remove_routing_forms/migration.sql b/packages/prisma/migrations/20260305043434_remove_routing_forms/migration.sql
index c2d3e08afe0..8d3d3e8fb57 100644
--- a/packages/prisma/migrations/20260305043434_remove_routing_forms/migration.sql
+++ b/packages/prisma/migrations/20260305043434_remove_routing_forms/migration.sql
@@ -82,141 +82,141 @@ DROP FUNCTION IF EXISTS trigger_cleanup_routing_form_response_denormalized_track
DROP FUNCTION IF EXISTS calculate_booking_status_order(text);
-- Pre-migration cleanup to allow enum variant removal
-DELETE FROM "public"."AssignmentReason" WHERE "reasonEnum" IN ('ROUTING_FORM_ROUTING', 'ROUTING_FORM_ROUTING_FALLBACK');
+DELETE FROM "AssignmentReason" WHERE "reasonEnum" IN ('ROUTING_FORM_ROUTING', 'ROUTING_FORM_ROUTING_FALLBACK');
-UPDATE "public"."Webhook"
-SET "eventTriggers" = array_remove("eventTriggers", 'ROUTING_FORM_FALLBACK_HIT'::"public"."WebhookTriggerEvents")
-WHERE "eventTriggers" @> ARRAY['ROUTING_FORM_FALLBACK_HIT']::"public"."WebhookTriggerEvents"[];
+UPDATE "Webhook"
+SET "eventTriggers" = array_remove("eventTriggers", 'ROUTING_FORM_FALLBACK_HIT'::"WebhookTriggerEvents")
+WHERE "eventTriggers" @> ARRAY['ROUTING_FORM_FALLBACK_HIT']::"WebhookTriggerEvents"[];
-DELETE FROM "public"."Workflow" WHERE "type" = 'ROUTING_FORM';
+DELETE FROM "Workflow" WHERE "type" = 'ROUTING_FORM';
-- AlterEnum
BEGIN;
-CREATE TYPE "public"."AssignmentReasonEnum_new" AS ENUM ('REASSIGNED', 'RR_REASSIGNED', 'REROUTED', 'SALESFORCE_ASSIGNMENT');
-ALTER TABLE "public"."AssignmentReason" ALTER COLUMN "reasonEnum" TYPE "public"."AssignmentReasonEnum_new" USING ("reasonEnum"::text::"public"."AssignmentReasonEnum_new");
-ALTER TYPE "public"."AssignmentReasonEnum" RENAME TO "AssignmentReasonEnum_old";
-ALTER TYPE "public"."AssignmentReasonEnum_new" RENAME TO "AssignmentReasonEnum";
-DROP TYPE "public"."AssignmentReasonEnum_old";
+CREATE TYPE "AssignmentReasonEnum_new" AS ENUM ('REASSIGNED', 'RR_REASSIGNED', 'REROUTED', 'SALESFORCE_ASSIGNMENT');
+ALTER TABLE "AssignmentReason" ALTER COLUMN "reasonEnum" TYPE "AssignmentReasonEnum_new" USING ("reasonEnum"::text::"AssignmentReasonEnum_new");
+ALTER TYPE "AssignmentReasonEnum" RENAME TO "AssignmentReasonEnum_old";
+ALTER TYPE "AssignmentReasonEnum_new" RENAME TO "AssignmentReasonEnum";
+DROP TYPE "AssignmentReasonEnum_old";
COMMIT;
-- AlterEnum
BEGIN;
-CREATE TYPE "public"."WebhookTriggerEvents_new" AS ENUM ('BOOKING_CREATED', 'BOOKING_PAYMENT_INITIATED', 'BOOKING_PAID', 'BOOKING_RESCHEDULED', 'BOOKING_REQUESTED', 'BOOKING_CANCELLED', 'BOOKING_REJECTED', 'BOOKING_NO_SHOW_UPDATED', 'FORM_SUBMITTED', 'MEETING_ENDED', 'MEETING_STARTED', 'RECORDING_READY', 'INSTANT_MEETING', 'RECORDING_TRANSCRIPTION_GENERATED', 'OOO_CREATED', 'AFTER_HOSTS_CAL_VIDEO_NO_SHOW', 'AFTER_GUESTS_CAL_VIDEO_NO_SHOW', 'FORM_SUBMITTED_NO_EVENT', 'DELEGATION_CREDENTIAL_ERROR', 'WRONG_ASSIGNMENT_REPORT');
-ALTER TABLE "public"."Webhook" ALTER COLUMN "eventTriggers" TYPE "public"."WebhookTriggerEvents_new"[] USING ("eventTriggers"::text::"public"."WebhookTriggerEvents_new"[]);
-ALTER TYPE "public"."WebhookTriggerEvents" RENAME TO "WebhookTriggerEvents_old";
-ALTER TYPE "public"."WebhookTriggerEvents_new" RENAME TO "WebhookTriggerEvents";
-DROP TYPE "public"."WebhookTriggerEvents_old";
+CREATE TYPE "WebhookTriggerEvents_new" AS ENUM ('BOOKING_CREATED', 'BOOKING_PAYMENT_INITIATED', 'BOOKING_PAID', 'BOOKING_RESCHEDULED', 'BOOKING_REQUESTED', 'BOOKING_CANCELLED', 'BOOKING_REJECTED', 'BOOKING_NO_SHOW_UPDATED', 'FORM_SUBMITTED', 'MEETING_ENDED', 'MEETING_STARTED', 'RECORDING_READY', 'INSTANT_MEETING', 'RECORDING_TRANSCRIPTION_GENERATED', 'OOO_CREATED', 'AFTER_HOSTS_CAL_VIDEO_NO_SHOW', 'AFTER_GUESTS_CAL_VIDEO_NO_SHOW', 'FORM_SUBMITTED_NO_EVENT', 'DELEGATION_CREDENTIAL_ERROR', 'WRONG_ASSIGNMENT_REPORT');
+ALTER TABLE "Webhook" ALTER COLUMN "eventTriggers" TYPE "WebhookTriggerEvents_new"[] USING ("eventTriggers"::text::"WebhookTriggerEvents_new"[]);
+ALTER TYPE "WebhookTriggerEvents" RENAME TO "WebhookTriggerEvents_old";
+ALTER TYPE "WebhookTriggerEvents_new" RENAME TO "WebhookTriggerEvents";
+DROP TYPE "WebhookTriggerEvents_old";
COMMIT;
-- AlterEnum
BEGIN;
-CREATE TYPE "public"."WorkflowType_new" AS ENUM ('EVENT_TYPE');
-ALTER TABLE "public"."Workflow" ALTER COLUMN "type" DROP DEFAULT;
-ALTER TABLE "public"."Workflow" ALTER COLUMN "type" TYPE "public"."WorkflowType_new" USING ("type"::text::"public"."WorkflowType_new");
-ALTER TYPE "public"."WorkflowType" RENAME TO "WorkflowType_old";
-ALTER TYPE "public"."WorkflowType_new" RENAME TO "WorkflowType";
-DROP TYPE "public"."WorkflowType_old";
-ALTER TABLE "public"."Workflow" ALTER COLUMN "type" SET DEFAULT 'EVENT_TYPE';
+CREATE TYPE "WorkflowType_new" AS ENUM ('EVENT_TYPE');
+ALTER TABLE "Workflow" ALTER COLUMN "type" DROP DEFAULT;
+ALTER TABLE "Workflow" ALTER COLUMN "type" TYPE "WorkflowType_new" USING ("type"::text::"WorkflowType_new");
+ALTER TYPE "WorkflowType" RENAME TO "WorkflowType_old";
+ALTER TYPE "WorkflowType_new" RENAME TO "WorkflowType";
+DROP TYPE "WorkflowType_old";
+ALTER TABLE "Workflow" ALTER COLUMN "type" SET DEFAULT 'EVENT_TYPE';
COMMIT;
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_Form" DROP CONSTRAINT "App_RoutingForms_Form_teamId_fkey";
+ALTER TABLE "App_RoutingForms_Form" DROP CONSTRAINT "App_RoutingForms_Form_teamId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_Form" DROP CONSTRAINT "App_RoutingForms_Form_updatedById_fkey";
+ALTER TABLE "App_RoutingForms_Form" DROP CONSTRAINT "App_RoutingForms_Form_updatedById_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_Form" DROP CONSTRAINT "App_RoutingForms_Form_userId_fkey";
+ALTER TABLE "App_RoutingForms_Form" DROP CONSTRAINT "App_RoutingForms_Form_userId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_FormResponse" DROP CONSTRAINT "App_RoutingForms_FormResponse_formId_fkey";
+ALTER TABLE "App_RoutingForms_FormResponse" DROP CONSTRAINT "App_RoutingForms_FormResponse_formId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_FormResponse" DROP CONSTRAINT "App_RoutingForms_FormResponse_routedToBookingUid_fkey";
+ALTER TABLE "App_RoutingForms_FormResponse" DROP CONSTRAINT "App_RoutingForms_FormResponse_routedToBookingUid_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_IncompleteBookingActions" DROP CONSTRAINT "App_RoutingForms_IncompleteBookingActions_formId_fkey";
+ALTER TABLE "App_RoutingForms_IncompleteBookingActions" DROP CONSTRAINT "App_RoutingForms_IncompleteBookingActions_formId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_QueuedFormResponse" DROP CONSTRAINT "App_RoutingForms_QueuedFormResponse_actualResponseId_fkey";
+ALTER TABLE "App_RoutingForms_QueuedFormResponse" DROP CONSTRAINT "App_RoutingForms_QueuedFormResponse_actualResponseId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."App_RoutingForms_QueuedFormResponse" DROP CONSTRAINT "App_RoutingForms_QueuedFormResponse_formId_fkey";
+ALTER TABLE "App_RoutingForms_QueuedFormResponse" DROP CONSTRAINT "App_RoutingForms_QueuedFormResponse_formId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."PendingRoutingTrace" DROP CONSTRAINT "PendingRoutingTrace_formResponseId_fkey";
+ALTER TABLE "PendingRoutingTrace" DROP CONSTRAINT "PendingRoutingTrace_formResponseId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."PendingRoutingTrace" DROP CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey";
+ALTER TABLE "PendingRoutingTrace" DROP CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingFormResponseDenormalized" DROP CONSTRAINT "RoutingFormResponseDenormalized_bookingId_fkey";
+ALTER TABLE "RoutingFormResponseDenormalized" DROP CONSTRAINT "RoutingFormResponseDenormalized_bookingId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingFormResponseDenormalized" DROP CONSTRAINT "RoutingFormResponseDenormalized_id_fkey";
+ALTER TABLE "RoutingFormResponseDenormalized" DROP CONSTRAINT "RoutingFormResponseDenormalized_id_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingFormResponseField" DROP CONSTRAINT "RoutingFormResponseField_responseId_fkey";
+ALTER TABLE "RoutingFormResponseField" DROP CONSTRAINT "RoutingFormResponseField_responseId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingFormResponseField" DROP CONSTRAINT "RoutingFormResponseField_response_fkey";
+ALTER TABLE "RoutingFormResponseField" DROP CONSTRAINT "RoutingFormResponseField_response_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingTrace" DROP CONSTRAINT "RoutingTrace_assignmentReasonId_fkey";
+ALTER TABLE "RoutingTrace" DROP CONSTRAINT "RoutingTrace_assignmentReasonId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingTrace" DROP CONSTRAINT "RoutingTrace_bookingUid_fkey";
+ALTER TABLE "RoutingTrace" DROP CONSTRAINT "RoutingTrace_bookingUid_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingTrace" DROP CONSTRAINT "RoutingTrace_formResponseId_fkey";
+ALTER TABLE "RoutingTrace" DROP CONSTRAINT "RoutingTrace_formResponseId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."RoutingTrace" DROP CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey";
+ALTER TABLE "RoutingTrace" DROP CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."WorkflowsOnRoutingForms" DROP CONSTRAINT "WorkflowsOnRoutingForms_routingFormId_fkey";
+ALTER TABLE "WorkflowsOnRoutingForms" DROP CONSTRAINT "WorkflowsOnRoutingForms_routingFormId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."WorkflowsOnRoutingForms" DROP CONSTRAINT "WorkflowsOnRoutingForms_workflowId_fkey";
+ALTER TABLE "WorkflowsOnRoutingForms" DROP CONSTRAINT "WorkflowsOnRoutingForms_workflowId_fkey";
-- DropForeignKey
-ALTER TABLE "public"."WrongAssignmentReport" DROP CONSTRAINT "WrongAssignmentReport_routingFormId_fkey";
+ALTER TABLE "WrongAssignmentReport" DROP CONSTRAINT "WrongAssignmentReport_routingFormId_fkey";
-- DropIndex
-DROP INDEX "public"."WrongAssignmentReport_routingFormId_idx";
+DROP INDEX "WrongAssignmentReport_routingFormId_idx";
-- AlterTable
-ALTER TABLE "public"."EventType" DROP COLUMN "redirectUrlOnNoRoutingFormResponse";
+ALTER TABLE "EventType" DROP COLUMN "redirectUrlOnNoRoutingFormResponse";
-- AlterTable
-ALTER TABLE "public"."WrongAssignmentReport" DROP COLUMN "routingFormId";
+ALTER TABLE "WrongAssignmentReport" DROP COLUMN "routingFormId";
-- DropTable
-DROP TABLE "public"."App_RoutingForms_Form";
+DROP TABLE "App_RoutingForms_Form";
-- DropTable
-DROP TABLE "public"."App_RoutingForms_FormResponse";
+DROP TABLE "App_RoutingForms_FormResponse";
-- DropTable
-DROP TABLE "public"."App_RoutingForms_IncompleteBookingActions";
+DROP TABLE "App_RoutingForms_IncompleteBookingActions";
-- DropTable
-DROP TABLE "public"."App_RoutingForms_QueuedFormResponse";
+DROP TABLE "App_RoutingForms_QueuedFormResponse";
-- DropTable
-DROP TABLE "public"."PendingRoutingTrace";
+DROP TABLE "PendingRoutingTrace";
-- DropTable
-DROP TABLE "public"."RoutingFormResponseDenormalized";
+DROP TABLE "RoutingFormResponseDenormalized";
-- DropTable
-DROP TABLE "public"."RoutingFormResponseField";
+DROP TABLE "RoutingFormResponseField";
-- DropTable
-DROP TABLE "public"."RoutingTrace";
+DROP TABLE "RoutingTrace";
-- DropTable
-DROP TABLE "public"."WorkflowsOnRoutingForms";
+DROP TABLE "WorkflowsOnRoutingForms";
-- DropEnum
-DROP TYPE "public"."IncompleteBookingActionType";
+DROP TYPE "IncompleteBookingActionType";
diff --git a/packages/prisma/migrations/20260430000000_drop_instant_meeting_webhook_trigger/migration.sql b/packages/prisma/migrations/20260430000000_drop_instant_meeting_webhook_trigger/migration.sql
index 6897fe0ed31..06d03fda17b 100644
--- a/packages/prisma/migrations/20260430000000_drop_instant_meeting_webhook_trigger/migration.sql
+++ b/packages/prisma/migrations/20260430000000_drop_instant_meeting_webhook_trigger/migration.sql
@@ -1,9 +1,9 @@
BEGIN;
-UPDATE "public"."Webhook"
-SET "eventTriggers" = array_remove("eventTriggers", 'INSTANT_MEETING'::"public"."WebhookTriggerEvents")
-WHERE "eventTriggers" @> ARRAY['INSTANT_MEETING']::"public"."WebhookTriggerEvents"[];
+UPDATE "Webhook"
+SET "eventTriggers" = array_remove("eventTriggers", 'INSTANT_MEETING'::"WebhookTriggerEvents")
+WHERE "eventTriggers" @> ARRAY['INSTANT_MEETING']::"WebhookTriggerEvents"[];
-CREATE TYPE "public"."WebhookTriggerEvents_new" AS ENUM (
+CREATE TYPE "WebhookTriggerEvents_new" AS ENUM (
'BOOKING_CREATED',
'BOOKING_PAYMENT_INITIATED',
'BOOKING_PAID',
@@ -24,10 +24,10 @@ CREATE TYPE "public"."WebhookTriggerEvents_new" AS ENUM (
'DELEGATION_CREDENTIAL_ERROR',
'WRONG_ASSIGNMENT_REPORT'
);
-ALTER TABLE "public"."Webhook"
-ALTER COLUMN "eventTriggers" TYPE "public"."WebhookTriggerEvents_new"[]
-USING ("eventTriggers"::text::"public"."WebhookTriggerEvents_new"[]);
-ALTER TYPE "public"."WebhookTriggerEvents" RENAME TO "WebhookTriggerEvents_old";
-ALTER TYPE "public"."WebhookTriggerEvents_new" RENAME TO "WebhookTriggerEvents";
-DROP TYPE "public"."WebhookTriggerEvents_old";
+ALTER TABLE "Webhook"
+ALTER COLUMN "eventTriggers" TYPE "WebhookTriggerEvents_new"[]
+USING ("eventTriggers"::text::"WebhookTriggerEvents_new"[]);
+ALTER TYPE "WebhookTriggerEvents" RENAME TO "WebhookTriggerEvents_old";
+ALTER TYPE "WebhookTriggerEvents_new" RENAME TO "WebhookTriggerEvents";
+DROP TYPE "WebhookTriggerEvents_old";
COMMIT;
diff --git a/packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql b/packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql
new file mode 100644
index 00000000000..1b4df321b73
--- /dev/null
+++ b/packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql
@@ -0,0 +1,123 @@
+-- Recreates the Workflow tables dropped by 20260319000000_drop_workflow_tables.
+-- The fork re-declares these models in schema.prisma, so the database needs them back.
+
+-- CreateEnum
+CREATE TYPE "WorkflowTriggerEvents" AS ENUM ('BEFORE_EVENT', 'AFTER_EVENT', 'NEW_EVENT', 'RESCHEDULE_EVENT', 'EVENT_CANCELLED');
+
+-- CreateEnum
+CREATE TYPE "WorkflowActions" AS ENUM ('EMAIL_HOST', 'EMAIL_ATTENDEE', 'EMAIL_ADDRESS', 'SMS_ATTENDEE', 'SMS_NUMBER', 'WHATSAPP_ATTENDEE', 'WHATSAPP_NUMBER');
+
+-- CreateEnum
+CREATE TYPE "WorkflowTemplates" AS ENUM ('REMINDER', 'CUSTOM');
+
+-- CreateEnum
+CREATE TYPE "WorkflowMethods" AS ENUM ('EMAIL', 'SMS', 'WHATSAPP');
+
+-- CreateTable
+CREATE TABLE "Workflow" (
+ "id" SERIAL NOT NULL,
+ "name" TEXT NOT NULL,
+ "userId" INTEGER,
+ "teamId" INTEGER,
+ "trigger" "WorkflowTriggerEvents" NOT NULL,
+ "time" INTEGER,
+ "timeUnit" "TimeUnit",
+ "isOrganiserEvent" BOOLEAN NOT NULL DEFAULT false,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Workflow_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "WorkflowStep" (
+ "id" SERIAL NOT NULL,
+ "stepNumber" INTEGER NOT NULL,
+ "action" "WorkflowActions" NOT NULL,
+ "workflowId" INTEGER NOT NULL,
+ "sendTo" TEXT,
+ "reminderBody" TEXT,
+ "emailSubject" TEXT,
+ "template" "WorkflowTemplates" NOT NULL DEFAULT 'REMINDER',
+ "sender" TEXT,
+ "numberRequired" BOOLEAN,
+ "numberVerificationPending" BOOLEAN NOT NULL DEFAULT false,
+ "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "WorkflowStep_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "WorkflowsOnEventTypes" (
+ "id" SERIAL NOT NULL,
+ "workflowId" INTEGER NOT NULL,
+ "eventTypeId" INTEGER NOT NULL,
+
+ CONSTRAINT "WorkflowsOnEventTypes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "WorkflowReminder" (
+ "id" SERIAL NOT NULL,
+ "bookingUid" TEXT,
+ "method" "WorkflowMethods" NOT NULL,
+ "scheduledDate" TIMESTAMP(3) NOT NULL,
+ "referenceId" TEXT,
+ "scheduled" BOOLEAN NOT NULL DEFAULT false,
+ "cancelled" BOOLEAN DEFAULT false,
+ "workflowStepId" INTEGER,
+ "seatReferenceId" TEXT,
+
+ CONSTRAINT "WorkflowReminder_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Workflow_userId_idx" ON "Workflow"("userId");
+
+-- CreateIndex
+CREATE INDEX "Workflow_teamId_idx" ON "Workflow"("teamId");
+
+-- CreateIndex
+CREATE INDEX "WorkflowStep_workflowId_idx" ON "WorkflowStep"("workflowId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "WorkflowsOnEventTypes_workflowId_eventTypeId_key" ON "WorkflowsOnEventTypes"("workflowId", "eventTypeId");
+
+-- CreateIndex
+CREATE INDEX "WorkflowsOnEventTypes_workflowId_idx" ON "WorkflowsOnEventTypes"("workflowId");
+
+-- CreateIndex
+CREATE INDEX "WorkflowsOnEventTypes_eventTypeId_idx" ON "WorkflowsOnEventTypes"("eventTypeId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "WorkflowReminder_referenceId_key" ON "WorkflowReminder"("referenceId");
+
+-- CreateIndex
+CREATE INDEX "WorkflowReminder_bookingUid_idx" ON "WorkflowReminder"("bookingUid");
+
+-- CreateIndex
+CREATE INDEX "WorkflowReminder_workflowStepId_idx" ON "WorkflowReminder"("workflowStepId");
+
+-- CreateIndex
+CREATE INDEX "WorkflowReminder_scheduledDate_scheduled_idx" ON "WorkflowReminder"("scheduledDate", "scheduled");
+
+-- AddForeignKey
+ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkflowsOnEventTypes" ADD CONSTRAINT "WorkflowsOnEventTypes_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkflowsOnEventTypes" ADD CONSTRAINT "WorkflowsOnEventTypes_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql b/packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql
new file mode 100644
index 00000000000..aef2711ab7c
--- /dev/null
+++ b/packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql
@@ -0,0 +1,16 @@
+-- Indexes for the DOS tenant lookups (MD-05) and the scheduled webhook drain (MD-06).
+-- Prisma cannot model jsonb expression indexes, so the two Team expression indexes below
+-- are hand-written here only; @@index([isOrganization]) and @@index([startAfter]) are
+-- declared in schema.prisma and created below to keep schema and database in sync.
+
+-- CreateIndex
+CREATE INDEX "Team_isOrganization_idx" ON "Team"("isOrganization");
+
+-- CreateIndex
+CREATE INDEX "Team_dosOrgId_idx" ON "Team" (("metadata"->>'dosOrgId')) WHERE "isOrganization" = true;
+
+-- CreateIndex
+CREATE INDEX "Team_dosTeamId_idx" ON "Team" (("metadata"->>'dosTeamId')) WHERE "isOrganization" = false;
+
+-- CreateIndex
+CREATE INDEX "WebhookScheduledTriggers_startAfter_idx" ON "WebhookScheduledTriggers"("startAfter");
diff --git a/packages/prisma/package.json b/packages/prisma/package.json
index 2876b4e3c44..4143379fbf8 100644
--- a/packages/prisma/package.json
+++ b/packages/prisma/package.json
@@ -33,7 +33,7 @@
"prisma-kysely": "2.2.0",
"uuid": "8.3.2",
"zod": "3.25.76",
- "zod-prisma-types": "3.2.4"
+ "zod-prisma-types": "3.3.11"
},
"devDependencies": {
"npm-run-all": "4.1.5",
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 743ca560407..2bfed727027 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -286,6 +286,7 @@ model EventType {
rrHostSubsetEnabled Boolean @default(false)
requiresCancellationReason CancellationReasonRequirement? @default(MANDATORY_HOST_ONLY)
enablePerHostLocations Boolean @default(false)
+ workflows WorkflowsOnEventTypes[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@ -465,6 +466,7 @@ model User {
verifiedNumbers VerifiedNumber[]
verifiedEmails VerifiedEmail[]
hosts Host[]
+ workflows Workflow[]
// organizationId is deprecated. Instead, rely on the Profile to search profiles by organizationId and then get user from the profile.
organizationId Int?
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
@@ -639,7 +641,8 @@ model Team {
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
integrationAttributeSyncs IntegrationAttributeSync[]
- autoOptInFeatures Boolean @default(false)
+ autoOptInFeatures Boolean @default(false)
+ workflows Workflow[]
// Monthly proration tracking
seatChangeLogs SeatChangeLog[]
@@ -647,6 +650,10 @@ model Team {
@@unique([slug, parentId])
@@index([parentId])
+ // Prunes the candidate set of the DOS tenant lookups, which filter on isOrganization
+ // plus a metadata jsonb path; expression indexes for those paths are hand-written in
+ // the SQL migration because Prisma cannot model them.
+ @@index([isOrganization])
}
model CreditBalance {
@@ -912,6 +919,7 @@ model Booking {
expenseLogs CreditExpenseLog[]
report BookingReport?
wrongAssignmentReports WrongAssignmentReport[]
+ workflowReminders WorkflowReminder[]
// @@partial_index([reassignById])
@@ -1325,6 +1333,8 @@ model WebhookScheduledTriggers {
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
// @@partial_index([bookingId])
+
+ @@index([startAfter])
}
model BookingSeat {
@@ -2849,3 +2859,105 @@ model AttributeSyncFieldMapping {
@@unique([integrationAttributeSyncId, attributeId])
@@index([integrationAttributeSyncId])
}
+
+// -----------------------------------------------------------------------------
+// WORKFLOWS & AUTOMATIONS (Phase 2)
+// -----------------------------------------------------------------------------
+
+enum WorkflowTriggerEvents {
+ BEFORE_EVENT
+ AFTER_EVENT
+ NEW_EVENT
+ RESCHEDULE_EVENT
+ EVENT_CANCELLED
+}
+
+enum WorkflowActions {
+ EMAIL_HOST
+ EMAIL_ATTENDEE
+ EMAIL_ADDRESS
+ SMS_ATTENDEE
+ SMS_NUMBER
+ WHATSAPP_ATTENDEE
+ WHATSAPP_NUMBER
+}
+
+enum WorkflowTemplates {
+ REMINDER
+ CUSTOM
+}
+
+enum WorkflowMethods {
+ EMAIL
+ SMS
+ WHATSAPP
+}
+
+model Workflow {
+ id Int @id @default(autoincrement())
+ name String
+ userId Int?
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
+ teamId Int?
+ team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ trigger WorkflowTriggerEvents
+ time Int?
+ timeUnit TimeUnit?
+ steps WorkflowStep[]
+ activeOn WorkflowsOnEventTypes[]
+ isOrganiserEvent Boolean @default(false)
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+
+ @@index([userId])
+ @@index([teamId])
+}
+
+model WorkflowStep {
+ id Int @id @default(autoincrement())
+ stepNumber Int
+ action WorkflowActions
+ workflowId Int
+ workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
+ sendTo String?
+ reminderBody String?
+ emailSubject String?
+ template WorkflowTemplates @default(REMINDER)
+ sender String?
+ numberRequired Boolean?
+ numberVerificationPending Boolean @default(false)
+ includeCalendarEvent Boolean @default(false)
+ reminders WorkflowReminder[]
+
+ @@index([workflowId])
+}
+
+model WorkflowsOnEventTypes {
+ id Int @id @default(autoincrement())
+ workflowId Int
+ workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
+ eventTypeId Int
+ eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
+
+ @@unique([workflowId, eventTypeId])
+ @@index([workflowId])
+ @@index([eventTypeId])
+}
+
+model WorkflowReminder {
+ id Int @id @default(autoincrement())
+ bookingUid String?
+ booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
+ method WorkflowMethods
+ scheduledDate DateTime
+ referenceId String? @unique
+ scheduled Boolean @default(false)
+ cancelled Boolean? @default(false)
+ workflowStepId Int?
+ workflowStep WorkflowStep? @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)
+ seatReferenceId String?
+
+ @@index([bookingUid])
+ @@index([workflowStepId])
+ @@index([scheduledDate, scheduled])
+}
diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json
index dfd82dba164..558cd89a3a7 100644
--- a/packages/testing/tsconfig.json
+++ b/packages/testing/tsconfig.json
@@ -1,7 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
- "target": "es5",
+ "target": "ES2022",
"jsx": "preserve",
"resolveJsonModule": true
},
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
index a653cc71df4..3bf0807ed5f 100644
--- a/packages/trpc/package.json
+++ b/packages/trpc/package.json
@@ -36,6 +36,6 @@
"@tanstack/react-query": "5.17.19",
"@types/cookie": "0.6.0",
"@types/uuid": "8.3.4",
- "typescript": "5.9.3"
+ "typescript": "6.0.3"
}
}
diff --git a/packages/trpc/server/middlewares/authRateLimitMiddleware.ts b/packages/trpc/server/middlewares/authRateLimitMiddleware.ts
new file mode 100644
index 00000000000..a131bcdbbf8
--- /dev/null
+++ b/packages/trpc/server/middlewares/authRateLimitMiddleware.ts
@@ -0,0 +1,75 @@
+import {
+ hashRateLimitIdentifier,
+ limitAuthRate,
+ type AuthRateLimiter,
+ type AuthRateLimitResult,
+} from "@calcom/lib/authRateLimiter";
+import { TRPCError } from "@trpc/server";
+
+type AuthIdentityRecord = Partial>;
+
+const AUTH_IDENTITY_KEYS = ["email", "username"] as const;
+
+/**
+ * Why: sensitive auth endpoints are brute-force targets and the shared
+ * `rateLimiter()` no-ops without UNKEY_ROOT_KEY. Buckets are keyed by
+ * route + IP + hashed identity so one identity cannot exhaust another's
+ * budget, and unauthenticated requests still get a per-IP budget.
+ */
+/**
+ * Why: called from an inline `.use(async (opts) => ...)` callback on each
+ * procedure so tRPC infers the context and preserves the procedure's output
+ * type; a standalone `middleware()` factory cannot unify its builder with
+ * authedProcedure's invariant generics.
+ */
+export async function enforceAuthRateLimit(
+ route: string,
+ ctx: { sourceIp?: string; user?: unknown },
+ input: unknown,
+ limiter?: AuthRateLimiter,
+): Promise {
+ const result = limitAuthRate(authRateLimitKey(route, ctx, input), limiter);
+ if (!result.allowed) {
+ throw tooManyRequestsError(result);
+ }
+}
+
+export function authRateLimitKey(route: string, ctx: { sourceIp?: string; user?: unknown }, input: unknown): string {
+ const identity = extractAuthIdentity(ctx, input);
+ // Unknown identities share a per-IP budget instead of bypassing the limit
+ return `${route}:${ctx.sourceIp ?? "unknown"}:${identity ? hashRateLimitIdentifier(identity) : "anonymous"}`;
+}
+
+function extractAuthIdentity(ctx: { user?: unknown }, input: unknown): string | null {
+ const fromInput = findFirstIdentityField(input);
+ if (fromInput) {
+ return fromInput;
+ }
+ const fromSession = findFirstIdentityField(ctx.user);
+ if (fromSession) {
+ return fromSession;
+ }
+ return null;
+}
+
+function findFirstIdentityField(value: unknown): string | null {
+ if (typeof value !== "object" || value === null) {
+ return null;
+ }
+ const record = value as AuthIdentityRecord;
+ for (const key of AUTH_IDENTITY_KEYS) {
+ const identity = record[key];
+ if (typeof identity === "string" && identity.trim().length > 0) {
+ return identity.trim().toLowerCase();
+ }
+ }
+ return null;
+}
+
+function tooManyRequestsError(result: AuthRateLimitResult): TRPCError {
+ const retryAfterSeconds = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
+ return new TRPCError({
+ code: "TOO_MANY_REQUESTS",
+ message: `Too many attempts. Try again in ${retryAfterSeconds} seconds.`,
+ });
+}
diff --git a/packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts b/packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts
new file mode 100644
index 00000000000..60475337e7c
--- /dev/null
+++ b/packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts
@@ -0,0 +1,80 @@
+import { MembershipRole } from "@calcom/prisma/enums";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { PermissionCheckService } from "../pbacProcedures";
+
+const { mockMembershipFindFirst } = vi.hoisted(() => ({ mockMembershipFindFirst: vi.fn() }));
+
+vi.mock("@calcom/prisma", () => ({
+ default: {
+ membership: {
+ findFirst: mockMembershipFindFirst,
+ },
+ },
+}));
+
+describe("PermissionCheckService.checkPermission", () => {
+ const service = new PermissionCheckService();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns false when the user is not a member of the team", async () => {
+ mockMembershipFindFirst.mockResolvedValue(null);
+
+ const hasPermission = await service.checkPermission({
+ userId: 1,
+ teamId: 5,
+ permission: "booking.update",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER],
+ });
+
+ expect(hasPermission).toBe(false);
+ });
+
+ it("returns true when the user has an accepted membership with a fallback role", async () => {
+ mockMembershipFindFirst.mockResolvedValue({ id: 101 });
+
+ const hasPermission = await service.checkPermission({
+ userId: 1,
+ teamId: 5,
+ permission: "booking.update",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER],
+ });
+
+ expect(hasPermission).toBe(true);
+ expect(mockMembershipFindFirst).toHaveBeenCalledWith({
+ where: {
+ userId: 1,
+ teamId: 5,
+ accepted: true,
+ role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER] },
+ },
+ select: { id: true },
+ });
+ });
+
+ it("returns false when the membership is not accepted", async () => {
+ // The query filters on `accepted: true`, so an unaccepted membership never matches.
+ mockMembershipFindFirst.mockResolvedValue(null);
+
+ const hasPermission = await service.checkPermission({
+ userId: 1,
+ teamId: 5,
+ permission: "booking.update",
+ fallbackRoles: [MembershipRole.MEMBER],
+ });
+
+ expect(hasPermission).toBe(false);
+ expect(mockMembershipFindFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: 1,
+ teamId: 5,
+ accepted: true,
+ role: { in: [MembershipRole.MEMBER] },
+ }),
+ })
+ );
+ });
+});
diff --git a/packages/trpc/server/procedures/pbacProcedures.ts b/packages/trpc/server/procedures/pbacProcedures.ts
index 1b57582410f..f9a61068992 100644
--- a/packages/trpc/server/procedures/pbacProcedures.ts
+++ b/packages/trpc/server/procedures/pbacProcedures.ts
@@ -1,14 +1,43 @@
+import type { PrismaClient } from "@calcom/prisma";
+import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import authedProcedure from "./authedProcedure";
type PermissionString = string;
+
+type CheckPermissionArgs = {
+ userId: number;
+ teamId: number;
+ permission: PermissionString;
+ fallbackRoles: MembershipRole[];
+};
+
class PermissionCheckService {
- constructor(_prisma?: unknown) {}
- async checkPermission(..._args: unknown[]) { return true; }
- async hasPermission(..._args: unknown[]) { return true; }
- async getTeamIdsWithPermission(..._args: unknown[]): Promise { return []; }
+ constructor(private readonly prismaClient: PrismaClient = prisma) {}
+
+ // The only current consumer's handler is a stub; this real membership check closes the silent
+ // no-op trap before real handlers land on PBAC procedures.
+ async checkPermission({ userId, teamId, fallbackRoles }: CheckPermissionArgs): Promise {
+ const membership = await this.prismaClient.membership.findFirst({
+ where: {
+ userId,
+ teamId,
+ accepted: true,
+ role: { in: fallbackRoles },
+ },
+ select: { id: true },
+ });
+ return membership !== null;
+ }
+
+ async hasPermission(..._args: unknown[]) {
+ return true;
+ }
+ async getTeamIdsWithPermission(..._args: unknown[]): Promise {
+ return [];
+ }
}
/**
@@ -95,4 +124,4 @@ function createOrgPbacProcedure(
});
}
-export { createTeamPbacProcedure, createOrgPbacProcedure };
+export { createTeamPbacProcedure, createOrgPbacProcedure, PermissionCheckService };
diff --git a/packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts b/packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts
new file mode 100644
index 00000000000..46129a44f9d
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts
@@ -0,0 +1,124 @@
+import type { AuthRateLimiter } from "@calcom/lib/authRateLimiter";
+import { createAuthRateLimiter } from "@calcom/lib/authRateLimiter";
+import { TRPCError } from "@trpc/server";
+import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
+
+import { enforceAuthRateLimit } from "../../../middlewares/authRateLimitMiddleware";
+
+const WINDOW_IN_MS = 60_000;
+const MAX_REQUESTS = 3;
+
+function buildTestLimiter(nowMs: number): { limiter: AuthRateLimiter; tick: (ms: number) => void } {
+ let now = nowMs;
+ const limiter = createAuthRateLimiter({
+ windowInMs: WINDOW_IN_MS,
+ maxRequests: MAX_REQUESTS,
+ now: () => now,
+ });
+ return {
+ limiter,
+ tick: (ms: number) => {
+ now += ms;
+ },
+ };
+}
+
+function buildMiddlewareInvocation({
+ sourceIp,
+ input,
+ limiter,
+}: {
+ sourceIp: string;
+ input: unknown;
+ limiter?: AuthRateLimiter;
+}) {
+ const next: Mock = vi.fn().mockResolvedValue({ ctx: {} });
+ return {
+ next,
+ invoke: async () => {
+ await enforceAuthRateLimit("verifyPassword", { sourceIp }, input, limiter);
+ return next();
+ },
+ };
+}
+
+describe("authRateLimitMiddleware", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("allows requests under the limit", async () => {
+ const { limiter } = buildTestLimiter(Date.now());
+ const { invoke } = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter });
+
+ await expect(invoke()).resolves.not.toThrow();
+ await expect(invoke()).resolves.not.toThrow();
+ await expect(invoke()).resolves.not.toThrow();
+ });
+
+ it("blocks requests over the limit with a 429 TOO_MANY_REQUESTS error", async () => {
+ const { limiter } = buildTestLimiter(Date.now());
+ const { invoke, next } = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter });
+
+ for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) {
+ await invoke();
+ }
+
+ await expect(invoke()).rejects.toMatchObject({
+ code: "TOO_MANY_REQUESTS",
+ });
+ await expect(invoke()).rejects.toBeInstanceOf(TRPCError);
+ expect(next).toHaveBeenCalledTimes(MAX_REQUESTS);
+ });
+
+ it("isolates buckets between different IPs", async () => {
+ const { limiter } = buildTestLimiter(Date.now());
+ const blocked = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter });
+ const otherIp = buildMiddlewareInvocation({ sourceIp: "198.51.100.77", input: { email: "user@example.com" }, limiter });
+
+ for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) {
+ await blocked.invoke();
+ }
+ await expect(blocked.invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
+
+ await expect(otherIp.invoke()).resolves.not.toThrow();
+ });
+
+ it("isolates buckets between different identities on the same IP", async () => {
+ const { limiter } = buildTestLimiter(Date.now());
+ const firstUser = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user1@example.com" }, limiter });
+ const secondUser = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user2@example.com" }, limiter });
+
+ for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) {
+ await firstUser.invoke();
+ }
+ await expect(firstUser.invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
+
+ await expect(secondUser.invoke()).resolves.not.toThrow();
+ });
+
+ it("allows requests again after the window expires", async () => {
+ const fixedStart = 1_700_000_000_000;
+ const { limiter, tick } = buildTestLimiter(fixedStart);
+ const { invoke } = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter });
+
+ for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) {
+ await invoke();
+ }
+ await expect(invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
+
+ // Crossing the window boundary resets the bucket count
+ tick(WINDOW_IN_MS + 1);
+ await expect(invoke()).resolves.not.toThrow();
+ });
+
+ it("keys unauthenticated requests by IP when no identity is available", async () => {
+ const { limiter } = buildTestLimiter(Date.now());
+ const anonymous = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: undefined, limiter });
+
+ for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) {
+ await anonymous.invoke();
+ }
+ await expect(anonymous.invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
+ });
+});
diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx
index d28acda561e..1f8f3c7bcab 100644
--- a/packages/trpc/server/routers/viewer/_router.tsx
+++ b/packages/trpc/server/routers/viewer/_router.tsx
@@ -22,10 +22,13 @@ import { i18nRouter } from "./i18n/_router";
import { meRouter } from "./me/_router";
import { oAuthRouter } from "./oAuth/_router";
import { oooRouter } from "./ooo/_router";
+import { viewerOrganizationsRouter } from "./organizations/_router";
import { slotsRouter } from "./slots/_router";
+import { viewerTeamsRouter } from "./teams/_router";
import { travelSchedulesRouter } from "./travelSchedules/_router";
import { userAdminRouter } from "./users/_router";
import { webhookRouter } from "./webhook/_router";
+import { viewerWorkflowsRouter } from "./workflows/_router";
export const viewerRouter = router({
loggedInViewerRouter,
@@ -43,11 +46,14 @@ export const viewerRouter = router({
availability: availabilityRouter,
timezones: timezonesRouter,
webhook: webhookRouter,
+ workflows: viewerWorkflowsRouter,
slots: slotsRouter,
i18n: i18nRouter,
features: featureFlagRouter,
feedback: feedbackRouter,
users: userAdminRouter,
+ teams: viewerTeamsRouter,
+ organizations: viewerOrganizationsRouter,
oAuth: oAuthRouter,
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx
index 860c33bd9a3..09823ee7a09 100644
--- a/packages/trpc/server/routers/viewer/auth/_router.tsx
+++ b/packages/trpc/server/routers/viewer/auth/_router.tsx
@@ -2,6 +2,7 @@ import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
import authedProcedure from "../../../procedures/authedProcedure";
import publicProcedure from "../../../procedures/publicProcedure";
+import { enforceAuthRateLimit } from "../../../middlewares/authRateLimitMiddleware";
import { router } from "../../../trpc";
import { ZChangePasswordInputSchema } from "./changePassword.schema";
import { ZResendVerifyEmailSchema } from "./resendVerifyEmail.schema";
@@ -19,55 +20,90 @@ type AuthRouterHandlerCache = {
};
export const authRouter = router({
- changePassword: authedProcedure.input(ZChangePasswordInputSchema).mutation(async ({ input, ctx }) => {
- const { changePasswordHandler } = await import("./changePassword.handler");
+ changePassword: authedProcedure
+ .use(async ({ ctx, input, next }) => {
+ await enforceAuthRateLimit("changePassword", ctx, input);
+ return next();
+ })
+ .input(ZChangePasswordInputSchema)
+ .mutation(async ({ input, ctx }) => {
+ const { changePasswordHandler } = await import("./changePassword.handler");
- return changePasswordHandler({
- ctx,
- input,
- });
- }),
+ return changePasswordHandler({
+ ctx,
+ input,
+ });
+ }),
- verifyPassword: authedProcedure.input(ZVerifyPasswordInputSchema).mutation(async ({ input, ctx }) => {
- const { verifyPasswordHandler } = await import("./verifyPassword.handler");
+ verifyPassword: authedProcedure
+ .use(async ({ ctx, input, next }) => {
+ await enforceAuthRateLimit("verifyPassword", ctx, input);
+ return next();
+ })
+ .input(ZVerifyPasswordInputSchema)
+ .mutation(async ({ input, ctx }) => {
+ const { verifyPasswordHandler } = await import("./verifyPassword.handler");
- return verifyPasswordHandler({
- ctx,
- input,
- });
- }),
+ return verifyPasswordHandler({
+ ctx,
+ input,
+ });
+ }),
- verifyCodeUnAuthenticated: publicProcedure.input(ZVerifyCodeInputSchema).mutation(async ({ input }) => {
- const { verifyCodeUnAuthenticatedHandler } = await import("./verifyCodeUnAuthenticated.handler");
+ verifyCodeUnAuthenticated: publicProcedure
+ .use(async ({ ctx, input, next }) => {
+ await enforceAuthRateLimit("verifyCodeUnAuthenticated", ctx, input);
+ return next();
+ })
+ .input(ZVerifyCodeInputSchema)
+ .mutation(async ({ input }) => {
+ const { verifyCodeUnAuthenticatedHandler } = await import("./verifyCodeUnAuthenticated.handler");
- return verifyCodeUnAuthenticatedHandler({
- input,
- });
- }),
+ return verifyCodeUnAuthenticatedHandler({
+ input,
+ });
+ }),
- sendVerifyEmailCode: publicProcedure.input(ZSendVerifyEmailCodeSchema).mutation(async ({ input, ctx }) => {
- const { sendVerifyEmailCodeHandler } = await import("./sendVerifyEmailCode.handler");
+ sendVerifyEmailCode: publicProcedure
+ .use(async ({ ctx, input, next }) => {
+ await enforceAuthRateLimit("sendVerifyEmailCode", ctx, input);
+ return next();
+ })
+ .input(ZSendVerifyEmailCodeSchema)
+ .mutation(async ({ input, ctx }) => {
+ const { sendVerifyEmailCodeHandler } = await import("./sendVerifyEmailCode.handler");
- return sendVerifyEmailCodeHandler({
- input,
- req: ctx.req,
- });
- }),
+ return sendVerifyEmailCodeHandler({
+ input,
+ req: ctx.req,
+ });
+ }),
- resendVerifyEmail: authedProcedure.input(ZResendVerifyEmailSchema).mutation(async ({ input, ctx }) => {
- const { resendVerifyEmail } = await import("./resendVerifyEmail.handler");
+ resendVerifyEmail: authedProcedure
+ .use(async ({ ctx, input, next }) => {
+ await enforceAuthRateLimit("resendVerifyEmail", ctx, input);
+ return next();
+ })
+ .input(ZResendVerifyEmailSchema)
+ .mutation(async ({ input, ctx }) => {
+ const { resendVerifyEmail } = await import("./resendVerifyEmail.handler");
- return resendVerifyEmail({
- input,
- ctx,
- });
- }),
+ return resendVerifyEmail({
+ input,
+ ctx,
+ });
+ }),
- createAccountPassword: authedProcedure.mutation(async ({ ctx }) => {
- const { createAccountPasswordHandler } = await import("./createAccountPassword.handler");
+ createAccountPassword: authedProcedure
+ .use(async ({ ctx, input, next }) => {
+ await enforceAuthRateLimit("createAccountPassword", ctx, input);
+ return next();
+ })
+ .mutation(async ({ ctx }) => {
+ const { createAccountPasswordHandler } = await import("./createAccountPassword.handler");
- return createAccountPasswordHandler({
- ctx,
- });
- }),
+ return createAccountPasswordHandler({
+ ctx,
+ });
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts
index bfe0e9d7fbd..bbc9fe9abbb 100644
--- a/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts
@@ -1,11 +1,11 @@
import { WrongAssignmentReportRepository } from "@calcom/features/bookings/repositories/WrongAssignmentReportRepository";
+import { ErrorWithCode } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TUpdateWrongAssignmentReportStatusInputSchema } from "./updateWrongAssignmentReportStatus.schema";
-
type UpdateWrongAssignmentReportStatusOptions = {
ctx: {
user: NonNullable;
@@ -31,6 +31,27 @@ export const updateWrongAssignmentReportStatusHandler = async ({
});
}
+ if (!report.teamId) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "You do not have permission to update this wrong assignment report"
+ );
+ }
+
+ const membership = await prisma.membership.findFirst({
+ where: {
+ userId: user.id,
+ teamId: report.teamId,
+ accepted: true,
+ role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
+ },
+ select: { id: true },
+ });
+
+ if (!membership) {
+ throw ErrorWithCode.Factory.Forbidden(
+ "You do not have permission to update this wrong assignment report"
+ );
+ }
const updatedReport = await repo.updateStatus({
id: reportId,
diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx
new file mode 100644
index 00000000000..b2fe8a46068
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx
@@ -0,0 +1,69 @@
+import { OrganizationService } from "@calcom/features/organizations/OrganizationService";
+import type { Prisma } from "@calcom/prisma";
+import { z } from "zod";
+import authedProcedure from "../../../procedures/authedProcedure";
+import { router } from "../../../trpc";
+
+export const ZGetOrgSchema = z.object({
+ orgId: z.number(),
+});
+
+export const ZUpdateOrgSchema = z.object({
+ orgId: z.number(),
+ name: z.string().min(1).optional(),
+ slug: z.string().optional(),
+ bio: z.string().optional(),
+ logoUrl: z.string().optional(),
+ metadata: z.record(z.unknown()).optional(),
+ lockEventTypeCreationForUsers: z.boolean().optional(),
+});
+
+export const ZCreateChildTeamSchema = z.object({
+ orgId: z.number(),
+ name: z.string().min(1),
+ slug: z.string().optional(),
+ description: z.string().optional(),
+});
+
+export const viewerOrganizationsRouter = router({
+ listCurrent: authedProcedure.query(async ({ ctx }) => {
+ const service = new OrganizationService(ctx.prisma);
+ return await service.findUserOrganizations({
+ userId: ctx.user.id,
+ });
+ }),
+
+ get: authedProcedure.input(ZGetOrgSchema).query(async ({ ctx, input }) => {
+ const service = new OrganizationService(ctx.prisma);
+ return await service.getOrganizationById({
+ orgId: input.orgId,
+ userId: ctx.user.id,
+ });
+ }),
+
+ update: authedProcedure.input(ZUpdateOrgSchema).mutation(async ({ ctx, input }) => {
+ const service = new OrganizationService(ctx.prisma);
+ return await service.updateOrganization({
+ orgId: input.orgId,
+ userId: ctx.user.id,
+ name: input.name,
+ slug: input.slug,
+ bio: input.bio,
+ logoUrl: input.logoUrl,
+ // z.record(z.unknown()) values are opaque to TS; Prisma only accepts JSON-serializable values here
+ metadata: input.metadata as unknown as Prisma.InputJsonValue,
+ lockEventTypeCreationForUsers: input.lockEventTypeCreationForUsers,
+ });
+ }),
+
+ createChildTeam: authedProcedure.input(ZCreateChildTeamSchema).mutation(async ({ ctx, input }) => {
+ const service = new OrganizationService(ctx.prisma);
+ return await service.createTeamUnderOrg({
+ orgId: input.orgId,
+ userId: ctx.user.id,
+ name: input.name,
+ slug: input.slug,
+ description: input.description,
+ });
+ }),
+});
diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx
new file mode 100644
index 00000000000..23b526aefff
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/teams/_router.tsx
@@ -0,0 +1,146 @@
+import { TeamService } from "@calcom/features/teams/TeamService";
+import type { Prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+import { z } from "zod";
+import authedProcedure from "../../../procedures/authedProcedure";
+import { router } from "../../../trpc";
+
+export const ZGetTeamSchema = z.object({
+ teamId: z.number(),
+});
+
+export const ZListTeamsSchema = z
+ .object({
+ includeOrgs: z.boolean().optional(),
+ })
+ .optional();
+
+export const ZCreateTeamSchema = z.object({
+ name: z.string().min(1),
+ slug: z.string().optional(),
+ description: z.string().optional(),
+ parentId: z.number().nullable().optional(),
+ isOrganization: z.boolean().optional(),
+});
+
+export const ZUpdateTeamSchema = z.object({
+ teamId: z.number(),
+ name: z.string().min(1).optional(),
+ slug: z.string().optional(),
+ bio: z.string().optional(),
+ logoUrl: z.string().optional(),
+ hideBookATeamMember: z.boolean().optional(),
+ metadata: z.record(z.unknown()).optional(),
+});
+
+export const ZDeleteTeamSchema = z.object({
+ teamId: z.number(),
+});
+
+export const ZInviteMemberSchema = z.object({
+ teamId: z.number(),
+ email: z.string().email(),
+ role: z.nativeEnum(MembershipRole).optional(),
+});
+
+export const ZChangeMemberRoleSchema = z.object({
+ teamId: z.number(),
+ targetUserId: z.number(),
+ role: z.nativeEnum(MembershipRole),
+});
+
+export const ZRemoveMemberSchema = z.object({
+ teamId: z.number(),
+ targetUserId: z.number(),
+});
+
+export const viewerTeamsRouter = router({
+ get: authedProcedure.input(ZGetTeamSchema).query(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.getTeamById({
+ teamId: input.teamId,
+ userId: ctx.user.id,
+ });
+ }),
+
+ list: authedProcedure.input(ZListTeamsSchema).query(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.findUserTeams({
+ userId: ctx.user.id,
+ includeOrgs: input?.includeOrgs,
+ });
+ }),
+
+ listOwnedTeams: authedProcedure.query(async ({ ctx }) => {
+ const service = new TeamService(ctx.prisma);
+ const teams = await service.findUserTeams({
+ userId: ctx.user.id,
+ includeOrgs: true,
+ });
+ return teams.filter((t) => t.role === MembershipRole.OWNER || t.role === MembershipRole.ADMIN);
+ }),
+
+ create: authedProcedure.input(ZCreateTeamSchema).mutation(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.createTeam({
+ userId: ctx.user.id,
+ name: input.name,
+ slug: input.slug,
+ description: input.description,
+ parentId: input.parentId,
+ isOrganization: input.isOrganization,
+ });
+ }),
+
+ update: authedProcedure.input(ZUpdateTeamSchema).mutation(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.updateTeam({
+ teamId: input.teamId,
+ userId: ctx.user.id,
+ name: input.name,
+ slug: input.slug,
+ bio: input.bio,
+ logoUrl: input.logoUrl,
+ hideBookATeamMember: input.hideBookATeamMember,
+ // z.record(z.unknown()) values are opaque to TS; Prisma only accepts JSON-serializable values here
+ metadata: input.metadata as unknown as Prisma.InputJsonValue,
+ });
+ }),
+
+ delete: authedProcedure.input(ZDeleteTeamSchema).mutation(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.deleteTeam({
+ teamId: input.teamId,
+ userId: ctx.user.id,
+ });
+ }),
+
+ inviteMember: authedProcedure.input(ZInviteMemberSchema).mutation(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.inviteMember({
+ teamId: input.teamId,
+ userId: ctx.user.id,
+ email: input.email,
+ role: input.role,
+ });
+ }),
+
+ changeMemberRole: authedProcedure.input(ZChangeMemberRoleSchema).mutation(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.changeMemberRole({
+ teamId: input.teamId,
+ userId: ctx.user.id,
+ targetUserId: input.targetUserId,
+ role: input.role,
+ });
+ }),
+
+ removeMember: authedProcedure.input(ZRemoveMemberSchema).mutation(async ({ ctx, input }) => {
+ const service = new TeamService(ctx.prisma);
+ return await service.removeMember({
+ teamId: input.teamId,
+ userId: ctx.user.id,
+ targetUserId: input.targetUserId,
+ });
+ }),
+});
diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx
new file mode 100644
index 00000000000..7135ce2a262
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx
@@ -0,0 +1,116 @@
+import { WorkflowService } from "@calcom/features/workflows/lib/WorkflowService";
+import {
+ TimeUnit,
+ WorkflowActions,
+ WorkflowTemplates,
+ WorkflowTriggerEvents,
+} from "@calcom/prisma/enums";
+import { z } from "zod";
+import authedProcedure from "../../../procedures/authedProcedure";
+import { router } from "../../../trpc";
+
+export const ZWorkflowStepSchema = z.object({
+ id: z.number().optional(),
+ stepNumber: z.number().int(),
+ action: z.nativeEnum(WorkflowActions),
+ sendTo: z.string().nullable().optional(),
+ reminderBody: z.string().nullable().optional(),
+ emailSubject: z.string().nullable().optional(),
+ template: z.nativeEnum(WorkflowTemplates).optional(),
+ sender: z.string().nullable().optional(),
+ numberRequired: z.boolean().nullable().optional(),
+ includeCalendarEvent: z.boolean().optional(),
+});
+
+export const ZCreateWorkflowSchema = z.object({
+ teamId: z.number().nullable().optional(),
+ name: z.string().min(1),
+ trigger: z.nativeEnum(WorkflowTriggerEvents),
+ time: z.number().int().nullable().optional(),
+ timeUnit: z.nativeEnum(TimeUnit).nullable().optional(),
+ steps: z.array(ZWorkflowStepSchema).min(1),
+ activeOn: z.array(z.number().int()).optional(),
+ isOrganiserEvent: z.boolean().optional(),
+});
+
+export const ZUpdateWorkflowSchema = z.object({
+ id: z.number().int(),
+ teamId: z.number().nullable().optional(),
+ name: z.string().min(1).optional(),
+ trigger: z.nativeEnum(WorkflowTriggerEvents).optional(),
+ time: z.number().int().nullable().optional(),
+ timeUnit: z.nativeEnum(TimeUnit).nullable().optional(),
+ active: z.boolean().optional(),
+ steps: z.array(ZWorkflowStepSchema).optional(),
+ activeOn: z.array(z.number().int()).optional(),
+ isOrganiserEvent: z.boolean().optional(),
+});
+
+export const ZGetWorkflowSchema = z.object({
+ id: z.number().int(),
+ teamId: z.number().nullable().optional(),
+});
+
+export const ZListWorkflowsSchema = z
+ .object({
+ teamId: z.number().nullable().optional(),
+ })
+ .optional();
+
+export const viewerWorkflowsRouter = router({
+ list: authedProcedure.input(ZListWorkflowsSchema).query(async ({ ctx, input }) => {
+ const service = new WorkflowService(ctx.prisma);
+ return await service.getWorkflows({
+ userId: ctx.user.id,
+ teamId: input?.teamId ?? null,
+ });
+ }),
+
+ get: authedProcedure.input(ZGetWorkflowSchema).query(async ({ ctx, input }) => {
+ const service = new WorkflowService(ctx.prisma);
+ return await service.getWorkflowById({
+ id: input.id,
+ userId: ctx.user.id,
+ teamId: input.teamId ?? null,
+ });
+ }),
+
+ create: authedProcedure.input(ZCreateWorkflowSchema).mutation(async ({ ctx, input }) => {
+ const service = new WorkflowService(ctx.prisma);
+ return await service.createWorkflow({
+ userId: ctx.user.id,
+ teamId: input.teamId ?? null,
+ input,
+ });
+ }),
+
+ update: authedProcedure.input(ZUpdateWorkflowSchema).mutation(async ({ ctx, input }) => {
+ const service = new WorkflowService(ctx.prisma);
+ return await service.updateWorkflow({
+ id: input.id,
+ userId: ctx.user.id,
+ teamId: input.teamId ?? null,
+ input,
+ });
+ }),
+
+ delete: authedProcedure.input(ZGetWorkflowSchema).mutation(async ({ ctx, input }) => {
+ const service = new WorkflowService(ctx.prisma);
+ return await service.deleteWorkflow({
+ id: input.id,
+ userId: ctx.user.id,
+ teamId: input.teamId ?? null,
+ });
+ }),
+
+ duplicate: authedProcedure.input(ZGetWorkflowSchema).mutation(async ({ ctx, input }) => {
+ const service = new WorkflowService(ctx.prisma);
+ return await service.duplicateWorkflow({
+ id: input.id,
+ userId: ctx.user.id,
+ teamId: input.teamId ?? null,
+ });
+ }),
+});
+
+export default viewerWorkflowsRouter;
diff --git a/packages/trpc/tsconfig.react.json b/packages/trpc/tsconfig.react.json
index 4db5bd83824..8c3f5ae7c25 100644
--- a/packages/trpc/tsconfig.react.json
+++ b/packages/trpc/tsconfig.react.json
@@ -11,6 +11,7 @@
],
"exclude": ["**/node_modules/**", "./server", "./react/**/*.test.ts", "./react/**/*.spec.ts"],
"compilerOptions": {
+ "rootDir": "react",
"isolatedModules": false,
"noEmit": false,
"declaration": true,
diff --git a/packages/trpc/tsconfig.server.json b/packages/trpc/tsconfig.server.json
index b3a251d7427..0b763b19cc4 100644
--- a/packages/trpc/tsconfig.server.json
+++ b/packages/trpc/tsconfig.server.json
@@ -11,6 +11,7 @@
],
"exclude": ["**/node_modules/**", "./react", "./server/**/*.test.ts", "./server/**/*.spec.ts"],
"compilerOptions": {
+ "rootDir": "server",
"isolatedModules": false,
"noEmit": false,
"declaration": true,
diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json
index 067aae8043a..9342a5b99de 100644
--- a/packages/tsconfig/base.json
+++ b/packages/tsconfig/base.json
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
+ "ignoreDeprecations": "6.0",
"composite": false,
"declaration": true,
"declarationMap": true,
diff --git a/packages/tsconfig/nextjs.json b/packages/tsconfig/nextjs.json
index 367eb4454ef..fe2e84a5b53 100644
--- a/packages/tsconfig/nextjs.json
+++ b/packages/tsconfig/nextjs.json
@@ -5,7 +5,7 @@
"compilerOptions": {
"declaration": false,
"declarationMap": false,
- "target": "es5",
+ "target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
diff --git a/packages/tsconfig/react-library.json b/packages/tsconfig/react-library.json
index 3e18d5383f4..1b7b3348370 100644
--- a/packages/tsconfig/react-library.json
+++ b/packages/tsconfig/react-library.json
@@ -5,7 +5,7 @@
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"module": "ESNext",
- "target": "ES5",
+ "target": "ES2022",
"jsx": "react-jsx"
},
"include": [".", "../types/next-auth.d.ts"],
diff --git a/packages/ui/components/credits/credits.test.tsx b/packages/ui/components/credits/credits.test.tsx
index d3cc9511c6c..bf5e48b64ed 100644
--- a/packages/ui/components/credits/credits.test.tsx
+++ b/packages/ui/components/credits/credits.test.tsx
@@ -8,6 +8,7 @@ vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
...actual,
+ COMPANY_NAME: "MetaDOS LLC",
CALCOM_VERSION: "mockedVersion",
};
});
@@ -16,7 +17,7 @@ describe("Tests for Credits component", () => {
test("Should render credits section with links", () => {
render();
- const creditsLinkElement = screen.getByRole("link", { name: /Cal\.com, Inc\./i });
+ const creditsLinkElement = screen.getByRole("link", { name: /MetaDOS LLC/i });
expect(creditsLinkElement).toBeInTheDocument();
expect(creditsLinkElement).toHaveAttribute("href", "https://go.cal.com/credits");
diff --git a/packages/ui/components/crove-app-switcher/CroveAppSwitcher.test.tsx b/packages/ui/components/crove-app-switcher/CroveAppSwitcher.test.tsx
new file mode 100644
index 00000000000..f916fcc68cd
--- /dev/null
+++ b/packages/ui/components/crove-app-switcher/CroveAppSwitcher.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, test, vi } from "vitest";
+import { CROVE_ECOSYSTEM_APPS, CroveAppSwitcher } from "./CroveAppSwitcher";
+
+vi.mock("@coss/ui/icons", () => ({
+ Grid3x3Icon: (props: any) => ,
+}));
+
+describe("CroveAppSwitcher Component", () => {
+ test("should render the app switcher trigger button with icon", () => {
+ render();
+ const trigger = screen.getByRole("button", { name: /Crove Ecosystem Apps/i });
+ expect(trigger).toBeInTheDocument();
+ expect(screen.getByTestId("grid-icon")).toBeInTheDocument();
+ });
+
+ test("should contain valid ecosystem product URLs", () => {
+ expect(CROVE_ECOSYSTEM_APPS.length).toBeGreaterThanOrEqual(8);
+ const calApp = CROVE_ECOSYSTEM_APPS.find((app) => app.id === "cal");
+ expect(calApp?.url).toBe("https://cal.crove.com");
+
+ const crmApp = CROVE_ECOSYSTEM_APPS.find((app) => app.id === "crm");
+ expect(crmApp?.url).toBe("https://crm.crove.com");
+
+ const postApp = CROVE_ECOSYSTEM_APPS.find((app) => app.id === "post");
+ expect(postApp?.url).toBe("https://post.crove.com");
+ });
+});
diff --git a/packages/ui/components/crove-app-switcher/CroveAppSwitcher.tsx b/packages/ui/components/crove-app-switcher/CroveAppSwitcher.tsx
new file mode 100644
index 00000000000..bafb945f326
--- /dev/null
+++ b/packages/ui/components/crove-app-switcher/CroveAppSwitcher.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import classNames from "@calcom/ui/classNames";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Grid3x3Icon } from "@coss/ui/icons";
+import type { ComponentProps } from "react";
+import { forwardRef } from "react";
+
+export interface CroveAppItem {
+ id: string;
+ name: string;
+ category: "crove" | "dos";
+ description: string;
+ url: string;
+ iconBg: string;
+ iconColor: string;
+ badge?: string;
+ isCurrent?: boolean;
+}
+
+export const CROVE_ECOSYSTEM_APPS: CroveAppItem[] = [
+ // Crove Suite
+ {
+ id: "cal",
+ name: "Crove Cal",
+ category: "crove",
+ description: "Scheduling & Meetings",
+ url: "https://cal.crove.com",
+ iconBg: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
+ iconColor: "text-blue-600 dark:text-blue-400",
+ isCurrent: true,
+ },
+ {
+ id: "crm",
+ name: "Crove CRM",
+ category: "crove",
+ description: "Sales & Customer CRM",
+ url: "https://crm.crove.com",
+ iconBg: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
+ iconColor: "text-emerald-600 dark:text-emerald-400",
+ },
+ {
+ id: "post",
+ name: "Crove Post",
+ category: "crove",
+ description: "Social Media & Content",
+ url: "https://post.crove.com",
+ iconBg: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
+ iconColor: "text-purple-600 dark:text-purple-400",
+ },
+ {
+ id: "sign",
+ name: "Crove Sign",
+ category: "crove",
+ description: "E-Signatures & Contracts",
+ url: "https://sign.crove.com",
+ iconBg: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
+ iconColor: "text-amber-600 dark:text-amber-400",
+ },
+ {
+ id: "desk",
+ name: "Crove Desk",
+ category: "crove",
+ description: "Helpdesk & AI Support",
+ url: "https://desk.crove.com",
+ iconBg: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
+ iconColor: "text-rose-600 dark:text-rose-400",
+ },
+ // DOS Ecosystem
+ {
+ id: "dos-id",
+ name: "DOS ID",
+ category: "dos",
+ description: "Central SSO & Identity",
+ url: "https://id.dos.me",
+ iconBg: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
+ iconColor: "text-cyan-600 dark:text-cyan-400",
+ },
+ {
+ id: "dos-me",
+ name: "DOS.Me",
+ category: "dos",
+ description: "Organizations & Workspace",
+ url: "https://dos.me",
+ iconBg: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400",
+ iconColor: "text-indigo-600 dark:text-indigo-400",
+ },
+ {
+ id: "dos-ai",
+ name: "DOS AI",
+ category: "dos",
+ description: "Enterprise AI Agents",
+ url: "https://ai.dos.me",
+ iconBg: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
+ iconColor: "text-violet-600 dark:text-violet-400",
+ },
+ {
+ id: "dosafe",
+ name: "DOSafe",
+ category: "dos",
+ description: "Security & Secret Vault",
+ url: "https://dosafe.me",
+ iconBg: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
+ iconColor: "text-teal-600 dark:text-teal-400",
+ },
+];
+
+export interface CroveAppSwitcherProps {
+ className?: string;
+ currentAppId?: string;
+}
+
+export const CroveAppSwitcher = forwardRef(
+ ({ className = "", currentAppId = "cal" }, forwardedRef) => {
+ const croveApps = CROVE_ECOSYSTEM_APPS.filter((app) => app.category === "crove");
+ const dosApps = CROVE_ECOSYSTEM_APPS.filter((app) => app.category === "dos");
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* Crove Suite Section */}
+