Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34ef206f8 | ||
|
|
1c3c661726 | ||
|
|
3dbad3113d | ||
|
|
b1173fc8c3 | ||
|
|
1c193cca86 | ||
|
|
17af50bbdc | ||
|
|
5242e419d9 | ||
|
|
5d65a0f091 | ||
|
|
4081d11fbe | ||
|
|
f883dee084 | ||
|
|
e3a9f54ba5 | ||
|
|
c9abc556ba | ||
|
|
9855176948 | ||
|
|
48cbb94af2 | ||
|
|
8238d4ffcd | ||
|
|
0a84ce5c73 | ||
|
|
7e73d675d7 | ||
|
|
648ad72a54 | ||
|
|
f00be08e8c | ||
|
|
f1e8e3fa58 | ||
|
|
4dbe044ecc | ||
|
|
f1ae5f727a | ||
|
|
7ee01c0487 | ||
|
|
05537780b8 | ||
|
|
3e29904d7d | ||
|
|
06f4b25080 | ||
|
|
79fc0c8437 | ||
|
|
a2b25fa615 | ||
|
|
14c151be76 | ||
|
|
1aae57daae | ||
|
|
12e95a1f47 | ||
|
|
60b6086221 | ||
|
|
5d65df9c05 | ||
|
|
66ce20245c | ||
|
|
c321a6c07a | ||
|
|
0eb2c15b39 | ||
|
|
9d29055808 | ||
|
|
74f3608b42 | ||
|
|
9bfa4165b9 | ||
|
|
8a96a45424 | ||
|
|
0787c4890e | ||
|
|
51e902f03e | ||
|
|
9b1bf29ecf | ||
|
|
ad65fbbd81 | ||
|
|
eef47ddefd | ||
|
|
8908a66c23 | ||
|
|
a78a3ffe39 | ||
|
|
a6a428c8cb | ||
|
|
217c6e6a76 | ||
|
|
b1eb5a2809 | ||
|
|
bd5d94a4ba | ||
|
|
3cfe295ed5 | ||
|
|
20dcef6680 | ||
|
|
11b65b295b | ||
|
|
4c73695d3a | ||
|
|
ab4eff1fe1 | ||
|
|
38b43f75ba | ||
|
|
9603831def | ||
|
|
2bc17313fd | ||
|
|
3a7122d613 | ||
|
|
21d28c9747 | ||
|
|
f8a93414a5 | ||
|
|
85e1ed3ab5 | ||
|
|
4647333839 | ||
|
|
9d4cb08c55 | ||
|
|
773fab858f | ||
|
|
ea0c92a267 | ||
|
|
2abefc412c | ||
|
|
9d4b12b8d3 | ||
|
|
c21281a55c | ||
|
|
f5a813f531 | ||
|
|
fa53f54815 | ||
|
|
f07bed1a2a | ||
|
|
227ed64b49 | ||
|
|
237d7e95c4 | ||
|
|
896dfd50fd | ||
|
|
e2119879ae | ||
|
|
7aefefc31f | ||
|
|
1b6b91b0ad | ||
|
|
50c7210d6b | ||
|
|
1bb4b206c6 | ||
|
|
6386b2ec95 | ||
|
|
68abc63fe4 | ||
|
|
bdeb4b1dc9 | ||
|
|
1541730886 | ||
|
|
4c6af5659a | ||
|
|
a9951df501 |
-57
@@ -1,57 +0,0 @@
|
||||
# EAS Build ignore file for monorepo
|
||||
# Only upload the companion app folder
|
||||
|
||||
# ===========================================
|
||||
# IGNORE EVERYTHING AT ROOT LEVEL
|
||||
# ===========================================
|
||||
/*
|
||||
|
||||
# ===========================================
|
||||
# EXCEPT THE COMPANION APP
|
||||
# ===========================================
|
||||
!companion
|
||||
|
||||
# ===========================================
|
||||
# BUT IGNORE THESE INSIDE COMPANION
|
||||
# ===========================================
|
||||
|
||||
# Native folders - EAS generates these during builds (~2.1 GB)
|
||||
companion/ios
|
||||
companion/android
|
||||
|
||||
# Dependencies - EAS installs these during builds (~1.6 GB)
|
||||
companion/node_modules
|
||||
|
||||
# Expo build cache
|
||||
companion/.expo
|
||||
companion/dist
|
||||
companion/web-build
|
||||
|
||||
# Metro
|
||||
companion/.metro-health-check*
|
||||
|
||||
# WXT Chrome Extension outputs (not needed for mobile builds)
|
||||
companion/.output
|
||||
companion/.wxt
|
||||
companion/dev
|
||||
|
||||
# Kotlin cache
|
||||
companion/.kotlin
|
||||
|
||||
# Build artifacts
|
||||
companion/*.jks
|
||||
companion/*.p8
|
||||
companion/*.p12
|
||||
companion/*.key
|
||||
companion/*.mobileprovision
|
||||
|
||||
# Debug logs
|
||||
companion/*.log
|
||||
|
||||
# TypeScript build info
|
||||
companion/*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
companion/.env
|
||||
companion/.env.*
|
||||
!companion/.env.example
|
||||
@@ -37,3 +37,11 @@
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||
|
||||
# Exclude test files from requiring foundation/consumer approval
|
||||
**/*.spec.*
|
||||
**/*.e2e.*
|
||||
**/*.e2e-spec.*
|
||||
**/*.test.*
|
||||
**/*.test-suite.*
|
||||
**/*.integration-test.*
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
name: Companion (iOS/Android/Extension)
|
||||
about: Report bugs or request features for mobile apps and browser extension
|
||||
title: ""
|
||||
labels: ["companion", "🚨 needs approval"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
### Issue Type
|
||||
|
||||
<!--
|
||||
Select one:
|
||||
- Bug Report
|
||||
- Feature Request
|
||||
-->
|
||||
|
||||
- [ ] 🐛 Bug Report
|
||||
- [ ] ✨ Feature Request
|
||||
|
||||
### Platform
|
||||
|
||||
<!--
|
||||
Select all that apply:
|
||||
-->
|
||||
|
||||
- [ ] 📱 iOS App
|
||||
- [ ] 🤖 Android App
|
||||
- [ ] 🧩 Browser Extension
|
||||
|
||||
### Platform Details
|
||||
|
||||
<!--
|
||||
Fill in the details for your selected platform(s).
|
||||
Delete sections that don't apply.
|
||||
-->
|
||||
|
||||
#### iOS
|
||||
- **iOS Version**: (e.g., 17.2)
|
||||
- **Device Model**: (e.g., iPhone 15 Pro)
|
||||
- **App Version**: (e.g., 1.0.0)
|
||||
|
||||
#### Android
|
||||
- **Android Version**: (e.g., 14)
|
||||
- **Device Model**: (e.g., Pixel 8)
|
||||
- **App Version**: (e.g., 1.0.0)
|
||||
|
||||
#### Browser Extension
|
||||
- **Browser & Version**: (e.g., Chrome 120, Firefox 121)
|
||||
- **Extension Version**: (e.g., 1.0.0)
|
||||
|
||||
---
|
||||
|
||||
### Description
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of the issue or feature.
|
||||
-->
|
||||
|
||||
(Write your description here.)
|
||||
|
||||
### Steps to Reproduce (Bug Reports Only)
|
||||
|
||||
<!--
|
||||
If this is a bug report, provide steps to reproduce the issue.
|
||||
Delete this section for feature requests.
|
||||
-->
|
||||
|
||||
1. Go to...
|
||||
2. Click on...
|
||||
3. See error...
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
<!--
|
||||
What did you expect to happen?
|
||||
-->
|
||||
|
||||
(Describe what you expected.)
|
||||
|
||||
### Actual Behavior (Bug Reports Only)
|
||||
|
||||
<!--
|
||||
What actually happened? Delete this section for feature requests.
|
||||
-->
|
||||
|
||||
(Describe what actually happened.)
|
||||
|
||||
### Proposed Solution (Feature Requests Only)
|
||||
|
||||
<!--
|
||||
If this is a feature request, describe your proposed solution.
|
||||
Delete this section for bug reports.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution.)
|
||||
|
||||
### Screenshots / Evidence
|
||||
|
||||
<!--
|
||||
Add screenshots, screen recordings, or console logs if applicable.
|
||||
You can use tools like Bird Eats Bug for recordings.
|
||||
-->
|
||||
|
||||
(Attach evidence here.)
|
||||
|
||||
### Additional Context
|
||||
|
||||
<!--
|
||||
Any other information that might be helpful.
|
||||
-->
|
||||
|
||||
(Add any additional context here.)
|
||||
|
||||
---
|
||||
|
||||
##### House rules
|
||||
|
||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves the request by removing this label.
|
||||
- Your ideas are invaluable! However, they undergo review to ensure alignment with the product's direction.
|
||||
- Follow best practices in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Companion Builds
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Companion App & Extension
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
- uses: ./.github/actions/cache-checkout
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.0
|
||||
cache: true
|
||||
cache-dependency-path: companion/bun.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build Expo web app
|
||||
working-directory: companion
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: 1
|
||||
CI: true
|
||||
run: bunx expo export --platform web
|
||||
|
||||
- name: Build browser extension
|
||||
working-directory: companion
|
||||
run: bun run ext:build
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Companion Lint
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Companion App
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
- uses: ./.github/actions/cache-checkout
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.0
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run lint
|
||||
working-directory: companion
|
||||
run: bun run lint:all
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Companion Type Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Type Check Companion App
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
- uses: ./.github/actions/cache-checkout
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.0
|
||||
cache: true
|
||||
cache-dependency-path: companion/bun.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
working-directory: companion
|
||||
run: bun run typecheck:all
|
||||
@@ -167,7 +167,6 @@ jobs:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
has-files-requiring-all-checks: ${{ steps.filter-exclusions.outputs.has-files-requiring-all-checks }}
|
||||
has-companion: ${{ steps.filter-inclusions.outputs.has-companion }}
|
||||
has-api-v2-changes: ${{ steps.filter-inclusions.outputs.has-api-v2-changes }}
|
||||
has-prisma-changes: ${{ steps.filter-inclusions.outputs.has-prisma-changes }}
|
||||
has-agents-changes: ${{ steps.filter-inclusions.outputs.has-agents-changes }}
|
||||
@@ -197,14 +196,13 @@ jobs:
|
||||
filters: |
|
||||
has-files-requiring-all-checks:
|
||||
- '**'
|
||||
- '!companion/**'
|
||||
- '!.vscode/**'
|
||||
- '!**/*.md'
|
||||
- '!**/*.mdx'
|
||||
- '!.github/CODEOWNERS'
|
||||
- '!docs/**'
|
||||
- '!help/**'
|
||||
- '!apps/web/public/static/locales/**/common.json'
|
||||
- '!packages/i18n/locales/**/common.json'
|
||||
- '!i18n.lock'
|
||||
- name: Check for specific path changes
|
||||
uses: dorny/paths-filter@v3
|
||||
@@ -213,8 +211,6 @@ jobs:
|
||||
filters: |
|
||||
has-agents-changes:
|
||||
- "agents/**"
|
||||
has-companion:
|
||||
- "companion/**"
|
||||
has-api-v2-changes:
|
||||
- "apps/api/v2/**"
|
||||
- "packages/platform-constants/**"
|
||||
@@ -387,27 +383,6 @@ jobs:
|
||||
uses: ./.github/workflows/docs-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-companion:
|
||||
name: Companion builds
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.has-companion == 'true'
|
||||
uses: ./.github/workflows/companion-build.yml
|
||||
secrets: inherit
|
||||
|
||||
typecheck-companion:
|
||||
name: Type Checks
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.has-companion == 'true'
|
||||
uses: ./.github/workflows/companion-typecheck.yml
|
||||
secrets: inherit
|
||||
|
||||
lint-companion:
|
||||
name: Linters
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.has-companion == 'true'
|
||||
uses: ./.github/workflows/companion-lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [prepare]
|
||||
@@ -489,9 +464,6 @@ jobs:
|
||||
build-api-v2,
|
||||
build-atoms,
|
||||
build-docs,
|
||||
build-companion,
|
||||
typecheck-companion,
|
||||
lint-companion,
|
||||
setup-db,
|
||||
e2e,
|
||||
e2e-api-v2,
|
||||
@@ -540,14 +512,6 @@ jobs:
|
||||
needs.e2e-app-store.result != 'success'
|
||||
)
|
||||
) ||
|
||||
(
|
||||
needs.prepare.outputs.has-companion == 'true' &&
|
||||
(
|
||||
needs.build-companion.result != 'success' ||
|
||||
needs.typecheck-companion.result != 'success' ||
|
||||
needs.lint-companion.result != 'success'
|
||||
)
|
||||
) ||
|
||||
(
|
||||
needs.prepare.outputs.has-agents-changes == 'true' &&
|
||||
needs.validate-agents-format.result != 'success'
|
||||
|
||||
@@ -12,6 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: false
|
||||
name: Sync to Devin Knowledge
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
|
||||
@@ -4,4 +4,10 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
# fast-xml-parser 4.4.1 via @boxyhq/saml-jackson → @aws-sdk/core@3.816.0 (transitive).
|
||||
# Only parses trusted AWS API responses, not user input. No practical attack vector.
|
||||
# Upstream fix pending: ory/polis (saml-jackson) has bumped to @aws-sdk@3.994.0 on main but hasn't released yet.
|
||||
npmAuditIgnoreAdvisories:
|
||||
- "1113407"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
|
||||
@@ -12,7 +12,7 @@ You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioriti
|
||||
- Create PRs in draft mode by default
|
||||
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
|
||||
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
|
||||
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
|
||||
- Add translations to `packages/i18n/locales/en/common.json` for all UI strings
|
||||
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
||||
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
||||
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
|
||||
@@ -126,7 +126,7 @@ packages/lib/ # Shared utilities
|
||||
- Routes: `apps/web/app/` (App Router)
|
||||
- Database schema: `packages/prisma/schema.prisma`
|
||||
- tRPC routers: `packages/trpc/server/routers/`
|
||||
- Translations: `apps/web/public/static/locales/en/common.json`
|
||||
- Translations: `packages/i18n/locales/en/common.json`
|
||||
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -149,6 +149,13 @@ Here is what you need to be able to run Cal.com.
|
||||
- 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. Setup 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:
|
||||
|
||||
@@ -33,7 +33,7 @@ tags: reference, navigation, file-locations
|
||||
|
||||
## Translations
|
||||
|
||||
- English: `apps/web/public/static/locales/en/common.json`
|
||||
- English: `packages/i18n/locales/en/common.json`
|
||||
|
||||
## App Store
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
---
|
||||
name: calcom-api
|
||||
description: Interact with the Cal.com API v2 to manage scheduling, bookings, event types, availability, and calendars. Use this skill when building integrations that need to create or manage bookings, check availability, configure event types, or sync calendars with Cal.com's scheduling infrastructure.
|
||||
env:
|
||||
CAL_API_KEY:
|
||||
description: "Cal.com API key (prefixed with cal_live_ or cal_test_). Required for all API requests."
|
||||
required: true
|
||||
CAL_CLIENT_ID:
|
||||
description: "OAuth client ID for platform integrations managing users on behalf of others. Sent as x-cal-client-id header."
|
||||
required: false
|
||||
CAL_SECRET_KEY:
|
||||
description: "OAuth client secret for platform integrations. Sent as x-cal-secret-key header."
|
||||
required: false
|
||||
CAL_WEBHOOK_SECRET:
|
||||
description: "Secret used to verify webhook payload signatures via X-Cal-Signature-256 header."
|
||||
required: false
|
||||
---
|
||||
|
||||
# Cal.com API v2
|
||||
@@ -14,6 +27,15 @@ All API requests should be made to:
|
||||
https://api.cal.com/v2
|
||||
```
|
||||
|
||||
## Required Credentials
|
||||
|
||||
| Environment Variable | Required | Description |
|
||||
|---------------------|----------|-------------|
|
||||
| `CAL_API_KEY` | Yes | Cal.com API key (prefixed with `cal_live_` or `cal_test_`). Used as Bearer token for all API requests. Generate from Settings > Developer > API Keys. |
|
||||
| `CAL_CLIENT_ID` | No | OAuth client ID for platform integrations that manage users on behalf of others. Sent as `x-cal-client-id` header. |
|
||||
| `CAL_SECRET_KEY` | No | OAuth client secret for platform integrations. Sent as `x-cal-secret-key` header. |
|
||||
| `CAL_WEBHOOK_SECRET` | No | Secret for verifying webhook payload signatures via the `X-Cal-Signature-256` header. |
|
||||
|
||||
## Authentication
|
||||
|
||||
All API requests require authentication via Bearer token:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
const path = require("node:path");
|
||||
const i18nConfig = require("@calcom/config/next-i18next.config");
|
||||
|
||||
/** @type {import("next-i18next").UserConfig} */
|
||||
const config = {
|
||||
...i18nConfig,
|
||||
localePath: path.resolve("../../web/public/static/locales"),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -82,6 +82,7 @@ async function handler(req: NextApiRequest) {
|
||||
return await handleCancelBooking({
|
||||
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
|
||||
userId: req.userId,
|
||||
actionSource: "API_V1",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// TODO: Fix tests (These test were never running due to the vitest workspace config)
|
||||
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
@@ -174,7 +173,7 @@ vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () =>
|
||||
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
vi.mock("@calcom/i18n/server", () => {
|
||||
const mockT = (key: string, options?: any) => {
|
||||
if (key === "event_between_users") {
|
||||
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
|
||||
|
||||
@@ -14,7 +14,7 @@ import handler from "../../../pages/api/users/_post";
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
vi.mock("@calcom/i18n/server", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => {
|
||||
return () => key;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const TRIGGER_VERSION = '20260212.3';
|
||||
export const TRIGGER_VERSION = '20260216.1';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require("node:path");
|
||||
const i18nConfig = require("@calcom/config/next-i18next.config");
|
||||
|
||||
/** @type {import("next-i18next").UserConfig} */
|
||||
const config = {
|
||||
...i18nConfig,
|
||||
localePath: path.resolve("../../web/public/static/locales"),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -62,7 +62,7 @@
|
||||
"@sentry/node": "9.46.0",
|
||||
"@sentry/profiling-node": "9.46.0",
|
||||
"@snyk/protect": "latest",
|
||||
"axios": "1.13.2",
|
||||
"axios": "1.13.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bull": "4.15.1",
|
||||
"class-transformer": "0.5.1",
|
||||
|
||||
@@ -279,6 +279,7 @@ export class BookingsController_2024_04_15 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
actionSource: "API_V2",
|
||||
});
|
||||
if (!res.onlyRemovedAttendee && res.isPlatformManagedUserBooking) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BookingAttendeesController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-attendees.controller";
|
||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||
import { BookingLocationController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-location.controller";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
@@ -12,17 +15,18 @@ import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-vid
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
@@ -50,8 +54,6 @@ import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -81,6 +83,7 @@ import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.m
|
||||
OAuthClientRepository,
|
||||
OAuthClientUsersService,
|
||||
BookingsService_2024_08_13,
|
||||
BookingAttendeesService_2024_08_13,
|
||||
BookingGuestsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
@@ -110,6 +113,7 @@ import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.m
|
||||
],
|
||||
controllers: [
|
||||
BookingsController_2024_08_13,
|
||||
BookingAttendeesController_2024_08_13,
|
||||
BookingGuestsController_2024_08_13,
|
||||
BookingLocationController_2024_08_13,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
|
||||
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
|
||||
import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings/:bookingUid/attendees",
|
||||
version: VERSION_2024_08_13_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings / Attendees")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
|
||||
example: VERSION_2024_08_13,
|
||||
required: true,
|
||||
})
|
||||
export class BookingAttendeesController_2024_08_13 {
|
||||
constructor(private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13) {}
|
||||
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||
@Throttle({
|
||||
limit: 5,
|
||||
ttl: 60000,
|
||||
blockDuration: 60000,
|
||||
name: "booking_attendees_add",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Add an attendee to a booking",
|
||||
description: `Add a new attendee to an existing booking by its UID.
|
||||
|
||||
**Side effects:**
|
||||
- The booking's attendee list is updated in the database
|
||||
- The calendar event is updated on connected calendars (Google Calendar, Outlook, etc.) to include the new attendee
|
||||
- An email notification is sent to the new attendee with the booking details
|
||||
|
||||
**Permissions:**
|
||||
- The authenticated user must be either the booking organizer, an existing attendee, or have the \`booking.update\` permission for the team
|
||||
|
||||
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
|
||||
`,
|
||||
})
|
||||
async addAttendee(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: AddAttendeeInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<AddAttendeeOutput_2024_08_13> {
|
||||
const attendee = await this.bookingAttendeesService.addAttendee(bookingUid, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attendee,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
AttendeeAddGuestsEmail,
|
||||
AttendeeScheduledEmail,
|
||||
OrganizerAddGuestsEmail,
|
||||
} from "@calcom/platform-libraries/emails";
|
||||
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { mockThrottlerGuard } from "test/utils/withNoThrottler";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { bootstrap } from "@/bootstrap";
|
||||
import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
|
||||
const attendeeAddGuestsEmailSpy = jest
|
||||
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const organizerAddGuestsEmailSpy = jest
|
||||
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const attendeeScheduledEmailSpy = jest
|
||||
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
|
||||
type TestUser = {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type TestSetup = {
|
||||
organizer: TestUser;
|
||||
unrelatedUser: TestUser;
|
||||
eventTypeId: number;
|
||||
bookingUid: string;
|
||||
bookingId: number;
|
||||
};
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13 add attendee", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
|
||||
let testSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockThrottlerGuard();
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `add-attendee-2024-08-13-organization-${randomString()}`,
|
||||
});
|
||||
|
||||
await setupTestData();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function setupTestData() {
|
||||
const oAuthClient = await createOAuthClient(organization.id, true);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `add-attendee-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const unrelatedUserData = await userRepositoryFixture.create({
|
||||
email: `add-attendee-2024-08-13-unrelated-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
|
||||
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
|
||||
unrelatedUserData.id,
|
||||
oAuthClient.id
|
||||
);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `add-attendee-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `add-attendee-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `add-attendee-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
testSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: {
|
||||
user: unrelatedUserData,
|
||||
accessToken: unrelatedUserTokens.accessToken,
|
||||
refreshToken: unrelatedUserTokens.refreshToken,
|
||||
},
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: "",
|
||||
bookingId: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
areEmailsEnabled: emailsEnabled,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/attendees", () => {
|
||||
beforeAll(async () => {
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: testSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Original Attendee",
|
||||
email: "original.attendee@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
testSetup.bookingUid = createBookingResponseBody.data.uid;
|
||||
testSetup.bookingId = createBookingResponseBody.data.id;
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("should return 401 when adding attendee without authentication", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "unauthenticated.attendee@example.com",
|
||||
name: "Unauthenticated Attendee",
|
||||
timeZone: "Europe/London",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization", () => {
|
||||
it("should allow booking organizer to add an attendee", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "new.attendee@example.com",
|
||||
name: "New Attendee",
|
||||
timeZone: "Europe/London",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: AddAttendeeOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.email).toEqual(addAttendeeBody.email);
|
||||
expect(responseBody.data.name).toEqual(addAttendeeBody.name);
|
||||
expect(responseBody.data.timeZone).toEqual(addAttendeeBody.timeZone);
|
||||
expect(responseBody.data.bookingId).toEqual(testSetup.bookingId);
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return 403 when unrelated user tries to add attendee", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "unauthorized.attendee@example.com",
|
||||
name: "Unauthorized Attendee",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Response format", () => {
|
||||
it("should return attendee with correct structure", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "structure.test@example.com",
|
||||
name: "Structure Test Attendee",
|
||||
timeZone: "Asia/Tokyo",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: AddAttendeeOutput_2024_08_13 = response.body;
|
||||
|
||||
expect(responseBody).toHaveProperty("status", SUCCESS_STATUS);
|
||||
expect(responseBody).toHaveProperty("data");
|
||||
expect(responseBody.data).toHaveProperty("id");
|
||||
expect(responseBody.data).toHaveProperty("bookingId");
|
||||
expect(responseBody.data).toHaveProperty("name");
|
||||
expect(responseBody.data).toHaveProperty("email");
|
||||
expect(responseBody.data).toHaveProperty("timeZone");
|
||||
|
||||
expect(typeof responseBody.data.id).toBe("number");
|
||||
expect(typeof responseBody.data.bookingId).toBe("number");
|
||||
expect(typeof responseBody.data.name).toBe("string");
|
||||
expect(typeof responseBody.data.email).toBe("string");
|
||||
expect(typeof responseBody.data.timeZone).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 400 when email is missing", async () => {
|
||||
const addAttendeeBody = {
|
||||
name: "No Email Attendee",
|
||||
timeZone: "Europe/London",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when email is invalid", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "invalid-email",
|
||||
name: "Invalid Email Attendee",
|
||||
timeZone: "Europe/London",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when name is missing", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "no.name@example.com",
|
||||
timeZone: "Europe/London",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when timeZone is missing", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "no.timezone@example.com",
|
||||
name: "No TimeZone Attendee",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when timeZone is invalid", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "invalid.timezone@example.com",
|
||||
name: "Invalid TimeZone Attendee",
|
||||
timeZone: "Invalid/TimeZone",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 403 when booking does not exist", async () => {
|
||||
const addAttendeeBody = {
|
||||
email: "nonexistent.booking@example.com",
|
||||
name: "Nonexistent Booking Attendee",
|
||||
timeZone: "Europe/London",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/non-existent-booking-uid/attendees`)
|
||||
.send(addAttendeeBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
|
||||
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
testSetup.organizer.user.id,
|
||||
testSetup.organizer.user.email
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||
}
|
||||
});
|
||||
@@ -1053,7 +1053,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
|
||||
expect(response.body.message).toEqual("Cancellation reason is required");
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingAttendee } from "@calcom/platform-types";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsNumber, ValidateNested } from "class-validator";
|
||||
|
||||
export class BookingAttendeeOutput_2024_08_13 extends BookingAttendee {
|
||||
@ApiProperty({ type: Number, example: 251 })
|
||||
@IsNumber()
|
||||
@Expose()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ type: Number, example: 313 })
|
||||
@IsNumber()
|
||||
@Expose()
|
||||
bookingId!: number;
|
||||
}
|
||||
|
||||
export class AddAttendeeOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: BookingAttendeeOutput_2024_08_13 })
|
||||
@ValidateNested()
|
||||
@Type(() => BookingAttendeeOutput_2024_08_13)
|
||||
data!: BookingAttendeeOutput_2024_08_13;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BookingAttendeesService } from "@calcom/platform-libraries/bookings";
|
||||
import type { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { HttpException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { BookingAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAttendeesService_2024_08_13 {
|
||||
private bookingAttendeesService: BookingAttendeesService;
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
) {
|
||||
this.bookingAttendeesService = new BookingAttendeesService();
|
||||
}
|
||||
|
||||
async addAttendee(
|
||||
bookingUid: string,
|
||||
input: AddAttendeeInput_2024_08_13,
|
||||
user: ApiAuthGuardUser
|
||||
): Promise<BookingAttendeeOutput_2024_08_13> {
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const createdAttendee = await this.bookingAttendeesService.addAttendee({
|
||||
bookingId: booking.id,
|
||||
attendee: {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
timeZone: input.timeZone,
|
||||
phoneNumber: input.phoneNumber,
|
||||
language: input.language,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
organizationId: user.organizationId,
|
||||
uuid: user.uuid,
|
||||
},
|
||||
emailsEnabled,
|
||||
actionSource: "API_V2",
|
||||
});
|
||||
|
||||
return plainToClass(
|
||||
BookingAttendeeOutput_2024_08_13,
|
||||
{
|
||||
id: createdAttendee.id,
|
||||
bookingId: createdAttendee.bookingId,
|
||||
name: createdAttendee.name,
|
||||
email: createdAttendee.email,
|
||||
displayEmail: this.getDisplayEmail(createdAttendee.email),
|
||||
timeZone: createdAttendee.timeZone,
|
||||
language: createdAttendee.locale ?? undefined,
|
||||
absent: false,
|
||||
phoneNumber: createdAttendee.phoneNumber ?? undefined,
|
||||
},
|
||||
{ strategy: "excludeAll" }
|
||||
);
|
||||
}
|
||||
|
||||
private getDisplayEmail(email: string): string {
|
||||
return email.replace(/\+[a-zA-Z0-9]{25}/, "");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
import { getWebhookProducer } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
import { WEBHOOK_PRODUCER } from "./regular-booking.tokens";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
@@ -37,6 +41,10 @@ import { Module, Scope } from "@nestjs/common";
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
{
|
||||
provide: WEBHOOK_PRODUCER,
|
||||
useFactory: () => getWebhookProducer(),
|
||||
},
|
||||
BookingAuditProducerService,
|
||||
BookingEventHandlerService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
@@ -51,6 +59,6 @@ import { Module, Scope } from "@nestjs/common";
|
||||
TaskerService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
exports: [RegularBookingService, WEBHOOK_PRODUCER],
|
||||
})
|
||||
export class RegularBookingModule {}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Injection token for IWebhookProducerService.
|
||||
* Used to bridge the Cal.com DI container into NestJS.
|
||||
*
|
||||
* Defined in a separate file to avoid a circular import between
|
||||
* regular-booking.module.ts and regular-booking.service.ts.
|
||||
*/
|
||||
export const WEBHOOK_PRODUCER = "WEBHOOK_PRODUCER";
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
RegularBookingService as BaseRegularBookingService,
|
||||
type IWebhookProducerService,
|
||||
} from "@calcom/platform-libraries/bookings";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { WEBHOOK_PRODUCER } from "@/lib/modules/regular-booking.tokens";
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
@@ -7,10 +14,6 @@ import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class RegularBookingService extends BaseRegularBookingService {
|
||||
@@ -23,7 +26,8 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
userRepository: PrismaUserRepository,
|
||||
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
|
||||
featuresRepository: PrismaFeaturesRepository,
|
||||
bookingEventHandler: BookingEventHandlerService
|
||||
bookingEventHandler: BookingEventHandlerService,
|
||||
@Inject(WEBHOOK_PRODUCER) webhookProducer: IWebhookProducerService
|
||||
) {
|
||||
super({
|
||||
checkBookingAndDurationLimitsService,
|
||||
@@ -35,6 +39,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
bookingEmailAndSmsTasker,
|
||||
featuresRepository,
|
||||
bookingEventHandler,
|
||||
webhookProducer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries/tasker";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TaskerService {
|
||||
private readonly tasker: Tasker;
|
||||
@@ -14,5 +13,3 @@ export class TaskerService {
|
||||
return this.tasker;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { generateSecret } from "@calcom/platform-libraries";
|
||||
import type { Membership, Team, User } from "@calcom/prisma/client";
|
||||
import { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { AccessScope, OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums";
|
||||
import type { INestApplication } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
@@ -21,20 +22,16 @@ import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
|
||||
// Mock next-auth/jwt getToken for ApiAuthStrategy NEXT_AUTH authentication
|
||||
// Mock next-auth/jwt getToken so the NextAuth fallback in ApiAuthStrategy doesn't crash in tests
|
||||
jest.mock("next-auth/jwt", () => ({
|
||||
getToken: jest.fn(),
|
||||
getToken: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
const mockGetToken = getToken as jest.MockedFunction<typeof getToken>;
|
||||
|
||||
describe("OAuth2 Controller Endpoints", () => {
|
||||
describe("User Not Authenticated", () => {
|
||||
let appWithoutAuth: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Mock getToken to return null for unauthenticated tests
|
||||
mockGetToken.mockResolvedValue(null);
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
@@ -69,20 +66,23 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Authenticated", () => {
|
||||
describe("User Authenticated (non-owner)", () => {
|
||||
let app: INestApplication;
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||
let oAuthService: OAuthService;
|
||||
|
||||
let user: User;
|
||||
let clientOwner: User;
|
||||
let authenticatedUser: User;
|
||||
let team: Team;
|
||||
let membership: Membership;
|
||||
let oAuthClient: { clientId: string };
|
||||
let apiKeyString: string;
|
||||
let refreshToken: string;
|
||||
|
||||
const testClientId = `test-oauth-client-${randomString()}`;
|
||||
@@ -96,7 +96,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
): Promise<string> {
|
||||
const result = await oAuthService.generateAuthorizationCode(
|
||||
testClientId,
|
||||
user.id,
|
||||
authenticatedUser.id,
|
||||
testRedirectUri,
|
||||
scopes,
|
||||
undefined,
|
||||
@@ -107,12 +107,6 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const userEmail = `oauth2-e2e-user-${randomString()}@api.com`;
|
||||
|
||||
// Mock getToken to return the user email for authenticated tests
|
||||
// This is needed because ApiAuthGuard uses ApiAuthStrategy which calls getToken from next-auth/jwt
|
||||
mockGetToken.mockResolvedValue({ email: userEmail });
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
@@ -125,20 +119,28 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||
oAuthService = moduleRef.get(OAuthService);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
clientOwner = await userRepositoryFixture.create({
|
||||
email: `oauth2-e2e-owner-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
authenticatedUser = await userRepositoryFixture.create({
|
||||
email: `oauth2-e2e-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(authenticatedUser.id, null);
|
||||
apiKeyString = keyString;
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `oauth2-e2e-team-${randomString()}`,
|
||||
slug: `oauth2-e2e-team-${randomString()}`,
|
||||
});
|
||||
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: user.id } },
|
||||
user: { connect: { id: authenticatedUser.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
@@ -151,6 +153,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
redirectUri: testRedirectUri,
|
||||
clientSecret: hashedSecret,
|
||||
clientType: OAuthClientType.CONFIDENTIAL,
|
||||
userId: clientOwner.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +162,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
it("should return OAuth client info for valid client ID", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("success");
|
||||
@@ -174,6 +178,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
it("should return 404 for non-existent client ID", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
@@ -434,7 +439,238 @@ describe("OAuth2 Controller Endpoints", () => {
|
||||
await oAuthClientFixture.delete(oAuthClient.clientId);
|
||||
await membershipRepositoryFixture.delete(membership.id);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
await userRepositoryFixture.delete(authenticatedUser.id);
|
||||
await userRepositoryFixture.delete(clientOwner.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Owner can use non-approved OAuth client", () => {
|
||||
let app: INestApplication;
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||
let oAuthService: OAuthService;
|
||||
|
||||
let owner: User;
|
||||
let team: Team;
|
||||
let membership: Membership;
|
||||
let pendingClient: { clientId: string };
|
||||
let rejectedClient: { clientId: string };
|
||||
let apiKeyString: string;
|
||||
|
||||
const pendingClientId = `test-pending-client-${randomString()}`;
|
||||
const rejectedClientId = `test-rejected-client-${randomString()}`;
|
||||
const testClientSecret = "test-secret-456";
|
||||
const testRedirectUri = "https://example.com/callback";
|
||||
|
||||
/** Generate a user-scoped authorization code (no teamSlug) so the owner's userId is in the token. */
|
||||
async function generateAuthCodeForClient(
|
||||
clientId: string,
|
||||
scopes: AccessScope[] = [AccessScope.READ_BOOKING]
|
||||
): Promise<string> {
|
||||
const result = await oAuthService.generateAuthorizationCode(
|
||||
clientId,
|
||||
owner.id,
|
||||
testRedirectUri,
|
||||
scopes
|
||||
);
|
||||
const redirectUrl = new URL(result.redirectUrl);
|
||||
return redirectUrl.searchParams.get("code") as string;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||
oAuthService = moduleRef.get(OAuthService);
|
||||
|
||||
owner = await userRepositoryFixture.create({
|
||||
email: `oauth2-owner-e2e-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(owner.id, null);
|
||||
apiKeyString = keyString;
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `oauth2-owner-team-${randomString()}`,
|
||||
slug: `oauth2-owner-team-${randomString()}`,
|
||||
});
|
||||
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: owner.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const [hashedSecret] = generateSecret(testClientSecret);
|
||||
|
||||
pendingClient = await oAuthClientFixture.create({
|
||||
clientId: pendingClientId,
|
||||
name: "Pending OAuth Client",
|
||||
redirectUri: testRedirectUri,
|
||||
clientSecret: hashedSecret,
|
||||
clientType: OAuthClientType.CONFIDENTIAL,
|
||||
status: OAuthClientStatus.PENDING,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
rejectedClient = await oAuthClientFixture.create({
|
||||
clientId: rejectedClientId,
|
||||
name: "Rejected OAuth Client",
|
||||
redirectUri: testRedirectUri,
|
||||
clientSecret: hashedSecret,
|
||||
clientType: OAuthClientType.CONFIDENTIAL,
|
||||
status: OAuthClientStatus.REJECTED,
|
||||
userId: owner.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return PENDING client info when authenticated as owner", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/auth/oauth2/clients/${pendingClientId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe("success");
|
||||
expect(response.body.data.client_id).toBe(pendingClientId);
|
||||
expect(response.body.data.name).toBe("Pending OAuth Client");
|
||||
});
|
||||
|
||||
it("should exchange authorization code for tokens with PENDING client owned by user", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.access_token).toBeDefined();
|
||||
expect(response.body.refresh_token).toBeDefined();
|
||||
expect(response.body.token_type).toBe("bearer");
|
||||
});
|
||||
|
||||
it("should refresh tokens with PENDING client owned by user", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
const tokenResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokenResponse.body.refresh_token,
|
||||
client_secret: testClientSecret,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.access_token).toBeDefined();
|
||||
expect(response.body.refresh_token).toBeDefined();
|
||||
expect(response.body.token_type).toBe("bearer");
|
||||
});
|
||||
|
||||
it("should reject authorization code generation for REJECTED client even as owner", async () => {
|
||||
await expect(generateAuthCodeForClient(rejectedClientId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should reject token exchange when client becomes rejected", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body.error).toBe("unauthorized_client");
|
||||
expect(response.body.error_description).toBe("client_rejected");
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should reject token refresh when client becomes rejected", async () => {
|
||||
const code = await generateAuthCodeForClient(pendingClientId);
|
||||
|
||||
const tokenResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: testRedirectUri,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/auth/oauth2/token")
|
||||
.type("form")
|
||||
.send({
|
||||
client_id: pendingClientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokenResponse.body.refresh_token,
|
||||
client_secret: testClientSecret,
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body.error).toBe("unauthorized_client");
|
||||
expect(response.body.error_description).toBe("client_rejected");
|
||||
|
||||
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oAuthClientFixture.delete(pendingClient.clientId);
|
||||
await oAuthClientFixture.delete(rejectedClient.clientId);
|
||||
await membershipRepositoryFixture.delete(membership.id);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await userRepositoryFixture.delete(owner.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,15 +16,16 @@ export class OAuth2HttpExceptionFilter implements ExceptionFilter<OAuth2HttpExce
|
||||
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
const { userEmail: _userEmail, ...safeUserContext } = extractUserContext(request);
|
||||
const { Authorization: _auth, ...safeHeaders } = filterReqHeaders(request.headers);
|
||||
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
|
||||
exception,
|
||||
body: "[REDACTED]",
|
||||
headers: filterReqHeaders(request.headers),
|
||||
headers: safeHeaders,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
...safeUserContext,
|
||||
});
|
||||
|
||||
response.status(exception.getStatus()).json(exception.oAuthErrorData);
|
||||
|
||||
+4
-4
@@ -515,7 +515,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${seatedEventTypeId}`,
|
||||
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
@@ -549,7 +549,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
});
|
||||
|
||||
bookingSeatsRepositoryFixture.create({
|
||||
referenceUid: randomString(),
|
||||
referenceUid: `seat-${randomString()}`,
|
||||
data: {},
|
||||
booking: {
|
||||
connect: {
|
||||
@@ -598,7 +598,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${seatedEventTypeId}`,
|
||||
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
@@ -632,7 +632,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
});
|
||||
|
||||
bookingSeatsRepositoryFixture.create({
|
||||
referenceUid: randomString(),
|
||||
referenceUid: `seat-${randomString()}`,
|
||||
data: {},
|
||||
booking: {
|
||||
connect: {
|
||||
|
||||
+10
-9
@@ -832,7 +832,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${seatedEventType.id}`,
|
||||
uid: `booking-uid-seated-${seatedEventType.id}-${randomString()}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
@@ -866,7 +866,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
|
||||
bookingSeatsRepositoryFixture.create({
|
||||
referenceUid: randomString(),
|
||||
referenceUid: `seat-${randomString()}`,
|
||||
data: {},
|
||||
booking: {
|
||||
connect: {
|
||||
@@ -961,7 +961,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
it("should do a booking for seated event and slot should show attendees count and bookingUid and return range format", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${seatedEventType.id}`,
|
||||
uid: `booking-uid-seated-range-${seatedEventType.id}-${randomString()}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
@@ -995,7 +995,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
|
||||
bookingSeatsRepositoryFixture.create({
|
||||
referenceUid: randomString(),
|
||||
referenceUid: `seat-${randomString()}`,
|
||||
data: {},
|
||||
booking: {
|
||||
connect: {
|
||||
@@ -1348,7 +1348,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
advanceTo(newDate);
|
||||
|
||||
const slotDuration = 60;
|
||||
const slotStartTime = "2050-09-05T10:00:00.000Z";
|
||||
// Use 2050-09-06 to avoid overlap with other tests that reserve 2050-09-05
|
||||
const slotStartTime = "2050-09-06T09:00:00.000Z";
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
@@ -1377,7 +1378,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`
|
||||
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-10&duration=60`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
@@ -1390,10 +1391,10 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
const expectedSlotsUTC2050_09_06 = expectedSlotsUTC["2050-09-06"].filter(
|
||||
(slot) => slot.start !== slotStartTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-06": expectedSlotsUTC2050_09_06 });
|
||||
|
||||
const dbSlot = await selectedSlotRepositoryFixture.getByUid(
|
||||
responseReservedVariableSlot.reservationUid
|
||||
@@ -1409,7 +1410,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
|
||||
it("request slot contains already existing reserved slot", async () => {
|
||||
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
|
||||
// Try to reserve 8:45-11:45 when 09:00-10:00 on 2050-09-06 is taken
|
||||
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
|
||||
.minus({ minutes: 15 })
|
||||
.toISO();
|
||||
|
||||
@@ -114,7 +114,11 @@ export class WorkflowsRepository {
|
||||
) {
|
||||
await updateWorkflow({
|
||||
ctx: {
|
||||
user: { ...user, locale: user?.locale ?? "en" },
|
||||
user: {
|
||||
...user,
|
||||
locale: user?.locale ?? "en",
|
||||
organizationId: user.profiles?.[0]?.organization?.id ?? null,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
},
|
||||
input: data,
|
||||
@@ -132,7 +136,11 @@ export class WorkflowsRepository {
|
||||
) {
|
||||
await updateWorkflow({
|
||||
ctx: {
|
||||
user: { ...user, locale: user?.locale ?? "en" },
|
||||
user: {
|
||||
...user,
|
||||
locale: user?.locale ?? "en",
|
||||
organizationId: user.profiles?.[0]?.organization?.id ?? null,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
},
|
||||
input: data,
|
||||
|
||||
@@ -21,6 +21,7 @@ export class OAuth2ClientRepositoryFixture {
|
||||
status?: OAuthClientStatus;
|
||||
logo?: string;
|
||||
isTrusted?: boolean;
|
||||
userId?: number;
|
||||
}) {
|
||||
return this.prismaWriteClient.oAuthClient.create({
|
||||
data: {
|
||||
@@ -32,10 +33,18 @@ export class OAuth2ClientRepositoryFixture {
|
||||
status: data.status || OAuthClientStatus.APPROVED,
|
||||
logo: data.logo,
|
||||
isTrusted: data.isTrusted || false,
|
||||
...(data.userId && { user: { connect: { id: data.userId } } }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(clientId: string, status: OAuthClientStatus) {
|
||||
return this.prismaWriteClient.oAuthClient.update({
|
||||
where: { clientId },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(clientId: string) {
|
||||
return this.prismaWriteClient.oAuthClient.delete({
|
||||
where: { clientId },
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const TRIGGER_VERSION = '20260212.3';
|
||||
export const TRIGGER_VERSION = '20260216.1';
|
||||
|
||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { generateMeetingMetadata } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizat
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FieldTypes } from "@calcom/app-store/routing-forms/lib/FieldTypes";
|
||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
|
||||
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -132,7 +133,13 @@ function Field({
|
||||
<TextField
|
||||
disabled={!!router}
|
||||
label={t("identifier_url_parameter")}
|
||||
hint={t("identifier_url_parameter_hint")}
|
||||
hint={
|
||||
<LearnMoreLink
|
||||
t={t}
|
||||
i18nKey="identifier_url_parameter_hint"
|
||||
href="https://cal.com/help/routing/connect-routing-form-to-booking-questions"
|
||||
/>
|
||||
}
|
||||
name={`${hookFieldNamespace}.identifier`}
|
||||
required
|
||||
placeholder={t("identifies_name_field")}
|
||||
|
||||
@@ -124,7 +124,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
<div className="col-span-2 mb-6">
|
||||
{date.locale(i18n.language ?? "en").format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} {t("minute_timeUnit")}{" "}
|
||||
<span className="text-subtle">({timezone})</span>
|
||||
</div>
|
||||
{props.booking.location && (
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state";
|
||||
|
||||
|
||||
+9
-8
@@ -154,9 +154,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
trackingMetadata: { section: "developer", page: "api_keys" },
|
||||
},
|
||||
{
|
||||
name: "admin_api",
|
||||
href: "/settings/organizations/admin-api",
|
||||
trackingMetadata: { section: "developer", page: "admin_api" },
|
||||
name: "api_docs",
|
||||
href: "https://cal.com/docs/api-reference/v2/introduction",
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "developer", page: "api_docs" },
|
||||
},
|
||||
// TODO: Add profile level for embeds
|
||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||
@@ -206,10 +207,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
trackingMetadata: { section: "organization", page: "directory_sync" },
|
||||
},
|
||||
{
|
||||
name: "admin_api",
|
||||
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
|
||||
name: "api_docs",
|
||||
href: "https://cal.com/docs/api-reference/v2/introduction",
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "organization", page: "admin_api" },
|
||||
trackingMetadata: { section: "organization", page: "api_docs" },
|
||||
},
|
||||
...(HAS_ORG_OPT_IN_FEATURES
|
||||
? [
|
||||
@@ -450,7 +451,7 @@ const useTabs = ({
|
||||
return { ...tab, children: filtered };
|
||||
} else if (tab.href === "/settings/developer") {
|
||||
const filtered = tab?.children?.filter(
|
||||
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "admin_api"
|
||||
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "api_docs"
|
||||
);
|
||||
return { ...tab, children: filtered };
|
||||
}
|
||||
@@ -824,7 +825,7 @@ const SettingsSidebarContainer = ({
|
||||
href={child.href || "/"}
|
||||
trackingMetadata={child.trackingMetadata}
|
||||
textClassNames="text-emphasis font-medium text-sm"
|
||||
className={`px-2! py-1! min-h-7 h-auto w-fit ${
|
||||
className={`px-2! py-1! min-h-7 h-auto w-full ${
|
||||
tab.children && index === tab.children?.length - 1 && "mb-3!"
|
||||
}`}
|
||||
disableChevron
|
||||
|
||||
@@ -21,7 +21,7 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={false}>
|
||||
borderInShellHeader={true}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={false}>
|
||||
borderInShellHeader={true}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+3
-29
@@ -1,33 +1,7 @@
|
||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { AdminAPIView } from "~/ee/organizations/admin-api";
|
||||
|
||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => `${t("admin")} ${t("api_reference")}`,
|
||||
(t) => t("leverage_our_api"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/admin-api"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrg();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={`${t("admin")} ${t("api_reference")}`}
|
||||
description={t("leverage_our_api")}
|
||||
borderInShellHeader={false}>
|
||||
<AdminAPIView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
const Page = () => {
|
||||
redirect("https://cal.com/docs/api-reference/v2/introduction");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { constructAppImage, constructGenericImage, constructMeetingImage } from
|
||||
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
|
||||
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
|
||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
import { truncateOnWord } from "@calcom/lib/text";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -17,11 +17,7 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ message: "email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let ip = getIP(req) ?? email.data;
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") as string;
|
||||
if (!ip && forwardedFor) {
|
||||
ip = forwardedFor?.split(",").at(0) ?? email.data;
|
||||
}
|
||||
const ip = getIP(req) ?? email.data;
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
validateAndGetCorrectedUsernameForTeam,
|
||||
} from "@calcom/features/auth/signup/utils/token";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
|
||||
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
|
||||
import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository";
|
||||
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
|
||||
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
|
||||
@@ -287,15 +287,6 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
|
||||
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
|
||||
await prefillAvatar({ email });
|
||||
}
|
||||
// Only send verification email for non-premium usernames
|
||||
// Premium usernames will get a magic link after payment in paymentCallback
|
||||
if (!checkoutSessionId) {
|
||||
sendEmailVerification({
|
||||
email,
|
||||
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
|
||||
username: username || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const featureRepository = getFeatureRepository();
|
||||
@@ -326,6 +317,14 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!checkoutSessionId && !token) {
|
||||
sendEmailVerification({
|
||||
email,
|
||||
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
|
||||
username: username || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (checkoutSessionId) {
|
||||
console.log("Created user but missing payment", checkoutSessionId);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager"
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
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";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendMonthlyDigestEmail } from "@calcom/emails/workflow-email-service";
|
||||
import { EventsInsights } from "@calcom/features/insights/server/events";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import renderEmail from "@calcom/emails/src/renderEmail";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
|
||||
/**
|
||||
* This API endpoint is used for development purposes to preview email templates
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const requestorIp = getIP(req);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
|
||||
|
||||
@@ -82,9 +82,9 @@ vi.mock("@calcom/features/booking-audit/lib/makeActor", () => ({
|
||||
makeUserActor: vi.fn().mockReturnValue({ type: "user", id: "test-uuid" }),
|
||||
}));
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
// Import after mocks are set up
|
||||
import { GET } from "../route";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const createMockRequest = (url: string): NextRequest => {
|
||||
const urlObj = new URL(url);
|
||||
@@ -97,13 +97,16 @@ const createMockRequest = (url: string): NextRequest => {
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
|
||||
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
|
||||
|
||||
describe("link route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET handler - redirect URL construction", () => {
|
||||
it("should redirect to booking page with the same origin as the request", async () => {
|
||||
it("should redirect to booking page using WEBAPP_URL (fixes localhost redirect when behind proxy)", async () => {
|
||||
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
@@ -113,11 +116,11 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
});
|
||||
|
||||
it("should preserve custom domain origin in redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for redirects, not request.url (avoids localhost when proxy sends localhost)", async () => {
|
||||
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
@@ -127,11 +130,11 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should preserve self-hosted domain origin in redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for self-hosted deployments", async () => {
|
||||
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
@@ -141,11 +144,11 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
|
||||
expect(location).not.toContain("localhost");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
});
|
||||
|
||||
it("should construct redirect URLs relative to the request URL for various origins", async () => {
|
||||
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
@@ -164,7 +167,7 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
}
|
||||
});
|
||||
@@ -174,7 +177,6 @@ describe("link route", () => {
|
||||
it("should redirect with error message when confirmHandler throws a TRPCError", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock confirmHandler to throw a TRPCError
|
||||
mockConfirmHandler.mockRejectedValueOnce(
|
||||
new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })
|
||||
);
|
||||
@@ -188,15 +190,14 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Custom error");
|
||||
});
|
||||
|
||||
it("should preserve origin in error redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for error redirects (not localhost when behind proxy)", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock confirmHandler to throw a TRPCError
|
||||
mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" }));
|
||||
|
||||
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
|
||||
@@ -208,7 +209,7 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import process from "node:process";
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
enum DirectAction {
|
||||
ACCEPT = "accept",
|
||||
@@ -99,11 +100,11 @@ async function handler(request: NextRequest) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, WEBAPP_URL)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, WEBAPP_URL));
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -42,6 +42,7 @@ const mockQueuedFormResponse = {
|
||||
disabled: false,
|
||||
},
|
||||
chosenRouteId: "mock-chosen-route-id",
|
||||
fallbackAction: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
response: {},
|
||||
@@ -126,6 +127,57 @@ describe("queuedResponseHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass fallbackAction to onSubmissionOfFormResponse when present", async () => {
|
||||
const fallbackAction = {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/fallback-event",
|
||||
eventTypeId: 42,
|
||||
};
|
||||
|
||||
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue({
|
||||
...mockQueuedFormResponse,
|
||||
fallbackAction,
|
||||
});
|
||||
|
||||
vi.mocked(getSerializableForm).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
position: 1,
|
||||
updatedById: null,
|
||||
disabled: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
|
||||
|
||||
vi.mocked(getResponseToStore).mockReturnValue({} as ReturnType<typeof getResponseToStore>);
|
||||
|
||||
vi.mocked(onSubmissionOfFormResponse).mockResolvedValue(undefined as unknown as Awaited<
|
||||
ReturnType<typeof onSubmissionOfFormResponse>
|
||||
>);
|
||||
|
||||
vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
|
||||
id: 1,
|
||||
response: {},
|
||||
} as Awaited<ReturnType<typeof mockRoutingFormResponseRepository.recordFormResponse>>);
|
||||
|
||||
await queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(onSubmissionOfFormResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackAction,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("if no queued form response is found, should return early", async () => {
|
||||
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
|
||||
const response = await queuedResponseHandler({
|
||||
|
||||
@@ -72,6 +72,12 @@ export const queuedResponseHandler = async ({
|
||||
}
|
||||
|
||||
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
|
||||
const fallbackAction = queuedFormResponse.fallbackAction as {
|
||||
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
|
||||
value: string;
|
||||
eventTypeId?: number;
|
||||
} | null;
|
||||
|
||||
await onSubmissionOfFormResponse({
|
||||
form: {
|
||||
...queuedFormResponse.form,
|
||||
@@ -79,6 +85,7 @@ export const queuedResponseHandler = async ({
|
||||
},
|
||||
formResponseInDb: formResponse,
|
||||
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
|
||||
fallbackAction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
@@ -80,6 +81,9 @@ function setMockRequestBody(body: Record<string, unknown>) {
|
||||
mockRequestBody = body;
|
||||
}
|
||||
|
||||
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
|
||||
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
|
||||
|
||||
function expectErrorRedirect(res: Response, path: string, error: string) {
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
@@ -88,8 +92,10 @@ function expectErrorRedirect(res: Response, path: string, error: string) {
|
||||
expect(redirectUrl.searchParams.get("error")).toBe(error);
|
||||
}
|
||||
|
||||
import process from "node:process";
|
||||
// Import after mocks are set up
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
const DB = {
|
||||
bookings: {} as Record<
|
||||
string,
|
||||
@@ -179,7 +185,22 @@ describe("verify-booking-token route", () => {
|
||||
});
|
||||
|
||||
describe("GET handler", () => {
|
||||
it("should redirect to booking page with error when action is reject (requires POST)", async () => {
|
||||
it("should process rejection via GET (for email clients that don't support POST)", async () => {
|
||||
createMockBooking({
|
||||
id: 1,
|
||||
uid: "abc123",
|
||||
oneTimePassword: "test-token",
|
||||
recurringEventId: null,
|
||||
});
|
||||
createMockUser({
|
||||
id: 42,
|
||||
uuid: "user-uuid",
|
||||
email: "test@example.com",
|
||||
username: "testuser",
|
||||
role: "USER",
|
||||
destinationCalendar: null,
|
||||
});
|
||||
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
@@ -189,9 +210,18 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method");
|
||||
expect(redirectUrl.searchParams.get("error")).toBeNull();
|
||||
expect(mockConfirmHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
bookingId: 1,
|
||||
confirmed: false,
|
||||
actionSource: "MAGIC_LINK",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect with error when query params are invalid (missing action)", async () => {
|
||||
@@ -205,7 +235,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
@@ -221,12 +251,12 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => {
|
||||
it("should use WEBAPP_URL for redirects (fixes localhost when behind proxy)", async () => {
|
||||
const baseUrl =
|
||||
"https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
@@ -237,7 +267,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
@@ -270,12 +300,12 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should preserve the request origin in POST redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for POST redirects (fixes localhost when behind proxy)", async () => {
|
||||
const baseUrl =
|
||||
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1&action=reject";
|
||||
const req = createMockRequest(baseUrl, "POST");
|
||||
@@ -286,13 +316,13 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirect URL construction", () => {
|
||||
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
|
||||
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
@@ -311,7 +341,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-uid");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
|
||||
enum DirectAction {
|
||||
ACCEPT = "accept",
|
||||
REJECT = "reject",
|
||||
@@ -28,21 +28,11 @@ async function getHandler(request: NextRequest) {
|
||||
try {
|
||||
const { action, token, bookingUid, userId } = querySchema.parse(queryParams);
|
||||
|
||||
if (action === DirectAction.REJECT) {
|
||||
// Rejections should use POST method
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`,
|
||||
request.url
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +49,7 @@ async function postHandler(request: NextRequest) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -70,7 +60,7 @@ async function handleBookingAction(
|
||||
token: string,
|
||||
bookingUid: string,
|
||||
userId: string,
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
reason?: string
|
||||
) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
@@ -79,7 +69,7 @@ async function handleBookingAction(
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -124,7 +114,7 @@ async function handleBookingAction(
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, WEBAPP_URL),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -134,7 +124,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, WEBAPP_URL), { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { loadTranslations } from "@calcom/i18n/server";
|
||||
import { IconSprites } from "@calcom/ui/components/icon";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { dir } from "i18next";
|
||||
|
||||
@@ -15,10 +15,17 @@ export default function PayPalSetup() {
|
||||
const [newSecretKey, setNewSecretKey] = useState("");
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "payment", appId: "paypal" });
|
||||
|
||||
const integrations = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "payment",
|
||||
appId: "paypal",
|
||||
});
|
||||
|
||||
const [paypalPaymentAppCredentials] = integrations.data?.items || [];
|
||||
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [-1];
|
||||
|
||||
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
|
||||
|
||||
const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("keys_have_been_saved"), "success");
|
||||
@@ -39,12 +46,13 @@ export default function PayPalSetup() {
|
||||
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
|
||||
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
|
||||
<div className="invisible md:visible">
|
||||
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt="Paypal Payment Logo" />
|
||||
<p className="text-default mt-5 text-lg">Paypal</p>
|
||||
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt={t("paypal_payment_logo")} />
|
||||
<p className="text-default mt-5 text-lg">{t("paypal")}</p>
|
||||
</div>
|
||||
|
||||
<form autoComplete="off" className="mt-5">
|
||||
<TextField
|
||||
label="Client Id"
|
||||
label={t("client_id")}
|
||||
type="text"
|
||||
name="client_id"
|
||||
id="client_id"
|
||||
@@ -55,7 +63,7 @@ export default function PayPalSetup() {
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Secret Key"
|
||||
label={t("secret_key")}
|
||||
type="password"
|
||||
name="access_token"
|
||||
id="access_token"
|
||||
@@ -65,7 +73,6 @@ export default function PayPalSetup() {
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Button to submit */}
|
||||
<div className="mt-5 flex flex-row justify-end">
|
||||
<Button
|
||||
color="primary"
|
||||
@@ -82,43 +89,39 @@ export default function PayPalSetup() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<p className="text-lgf text-default mt-5 font-bold">Getting started with Paypal APP</p>
|
||||
<p className="text-default font-semi mt-2">
|
||||
Here in Cal.com we offer Paypal as one of our payment gateway. You can use your own Paypal
|
||||
Business account to receive payments from your customers enabling and setting up price and
|
||||
currency for each of your event types.
|
||||
</p>
|
||||
<p className="text-lgf text-default mt-5 font-bold">{t("paypal_getting_started")}</p>
|
||||
|
||||
<p className="text-default font-semi mt-2">{t("paypal_description")}</p>
|
||||
|
||||
<p className="text-lgf text-default mt-5 inline-flex font-bold">
|
||||
<CircleAlertIcon className="mr-2 mt-1 h-4 w-4" /> Important requirements:
|
||||
<CircleAlertIcon className="mr-2 mt-1 h-4 w-4" />
|
||||
{t("important_requirements")}
|
||||
</p>
|
||||
|
||||
<ul className="text-default ml-1 mt-2 list-disc pl-2">
|
||||
<li>Paypal Business account</li>
|
||||
<li>Paypal Developer account</li>
|
||||
<li>{t("paypal_business_account")}</li>
|
||||
<li>{t("paypal_developer_account")}</li>
|
||||
</ul>
|
||||
|
||||
<p className="text-default mb-2 mt-5 font-bold">Resources:</p>
|
||||
<p className="text-default mb-2 mt-5 font-bold">{t("resources")}</p>
|
||||
|
||||
<a
|
||||
className="text-orange-600 underline"
|
||||
target="_blank"
|
||||
href="https://developer.paypal.com/api/rest/#link-getclientidandclientsecret"
|
||||
rel="noreferrer">
|
||||
Link to Paypal developer API REST Setup Guide:
|
||||
https://developer.paypal.com/api/rest/#link-getclientidandclientsecret
|
||||
{t("paypal_rest_setup_guide")}
|
||||
</a>
|
||||
|
||||
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
|
||||
<p className="text-default font-semi mt-2">
|
||||
Remember to only proceed with the following steps if your account has already been upgraded to
|
||||
a business account. Also keep in mind that some of the following steps might be different
|
||||
since Paypal offers different experiences based on your country.
|
||||
</p>
|
||||
<p className="text-lgf text-default mt-5 font-bold">{t("paypal_setup_instructions")}</p>
|
||||
|
||||
<p className="text-default font-semi mt-2">{t("paypal_setup_note")}</p>
|
||||
|
||||
<ol className="text-default ml-1 mt-5 list-decimal pl-2">
|
||||
{/* @TODO: translate */}
|
||||
<li>
|
||||
Log into your Paypal Developer account and create a new app{" "}
|
||||
{t("paypal_step_1")}{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://developer.paypal.com/dashboard/applications/live"
|
||||
@@ -128,39 +131,38 @@ export default function PayPalSetup() {
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Choose a name for your application.</li>
|
||||
<li>Click on the Create App button</li>
|
||||
|
||||
<li>{t("paypal_step_2")}</li>
|
||||
<li>{t("paypal_step_3")}</li>
|
||||
|
||||
<li>
|
||||
Go back to{" "}
|
||||
{t("paypal_step_4_part_1")}{" "}
|
||||
<a
|
||||
className="text-orange-600 underline"
|
||||
href="https://developer.paypal.com/dashboard/applications/live">
|
||||
dashboard
|
||||
{t("dashboard")}
|
||||
</a>
|
||||
, click on new app created.
|
||||
, {t("paypal_step_4_part_2")}
|
||||
</li>
|
||||
<li>Copy the Client ID and Secret Key using copy buttons one by one.</li>
|
||||
<li>Paste them on the required field and save them.</li>
|
||||
<li>You should be all setup after this.</li>
|
||||
|
||||
<li>{t("paypal_step_5")}</li>
|
||||
<li>{t("paypal_step_6")}</li>
|
||||
<li>{t("paypal_step_7")}</li>
|
||||
</ol>
|
||||
|
||||
<p className="text-default mt-5 inline-flex font-bold">
|
||||
<CircleAlertIcon className="mr-2 mt-1 h-4 w-4" />
|
||||
Reminder:
|
||||
</p>
|
||||
<p className="text-default mt-2">
|
||||
Our integration creates a specific webhook on your Paypal account that we use to report back
|
||||
transactions to our system. If you delete this webhook, we will not be able to report back and
|
||||
you should Uninstall and Install the app again for this to work again. Uninstalling the app
|
||||
won't delete your current event type price/currency configuration but you would not be
|
||||
able to receive bookings.
|
||||
{t("reminder")}:
|
||||
</p>
|
||||
|
||||
<p className="text-default mt-2">{t("paypal_webhook_reminder")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AppNotInstalledMessage appName="paypal" />
|
||||
)}
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { isCancellationReasonRequired } from "@calcom/features/bookings/lib/cancellationReason";
|
||||
import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib/payment/shouldChargeNoShowCancellationFee";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
|
||||
import { CheckboxField, Label, Select, TextArea } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { InfoIcon, XIcon } from "@coss/ui/icons";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface InternalNotePresetsSelectProps {
|
||||
internalNotePresets: { id: number; name: string }[];
|
||||
@@ -46,6 +47,7 @@ const InternalNotePresetsSelect = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-col">
|
||||
<Label>{t("internal_booking_note")}</Label>
|
||||
@@ -109,6 +111,7 @@ type Props = {
|
||||
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
|
||||
renderContext: "booking-single-view" | "dialog";
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
requiresCancellationReason?: CancellationReasonRequirement | null;
|
||||
showErrorAsToast?: boolean;
|
||||
onCanceled?: () => void;
|
||||
};
|
||||
@@ -162,11 +165,18 @@ export default function CancelBooking(props: Props) {
|
||||
const isCancellationUserHost =
|
||||
props.isHost || bookingCancelledEventProps.organizer.email === currentUserEmail;
|
||||
|
||||
const hostMissingCancellationReason =
|
||||
isCancellationUserHost &&
|
||||
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
|
||||
const isReasonRequired = isCancellationReasonRequired(
|
||||
props.requiresCancellationReason,
|
||||
isCancellationUserHost
|
||||
);
|
||||
|
||||
const missingRequiredReason = isReasonRequired && !cancellationReason?.trim();
|
||||
const hostMissingInternalNote =
|
||||
isCancellationUserHost && props.internalNotePresets.length > 0 && !internalNote?.id;
|
||||
const cancellationNoShowFeeNotAcknowledged =
|
||||
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
|
||||
const canCancel =
|
||||
!missingRequiredReason && !hostMissingInternalNote && !cancellationNoShowFeeNotAcknowledged;
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
|
||||
@@ -177,6 +187,63 @@ export default function CancelBooking(props: Props) {
|
||||
|
||||
const isRenderedAsCancelDialog = props.renderContext === "dialog";
|
||||
|
||||
const handleCancel =async()=>{
|
||||
try{
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
|
||||
const { csrfToken } = await response.json();
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify({
|
||||
uid: booking?.uid,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
// @NOTE: very important this shouldn't cancel with number ID use uid instead
|
||||
seatReferenceUid,
|
||||
cancelledBy: currentUserEmail,
|
||||
internalNote: internalNote,
|
||||
csrfToken,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const bookingWithCancellationReason = {
|
||||
...(bookingCancelledEventProps.booking as object),
|
||||
cancellationReason,
|
||||
} as unknown;
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
sdkActionManager?.fire("bookingCancelled", {
|
||||
...bookingCancelledEventProps,
|
||||
booking: bookingWithCancellationReason,
|
||||
});
|
||||
refreshData();
|
||||
if (props.onCanceled) {
|
||||
props.onCanceled();
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const errorMessage =
|
||||
data.message ||
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`;
|
||||
|
||||
if (props.showErrorAsToast) {
|
||||
showToast(errorMessage, "error");
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
} finally{
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && !props.showErrorAsToast && (
|
||||
@@ -217,7 +284,7 @@ export default function CancelBooking(props: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Label>{isCancellationUserHost ? t("cancellation_reason_host") : t("cancellation_reason")}</Label>
|
||||
<Label>{t(isReasonRequired ? "cancellation_reason" : "cancellation_reason_optional_label")}</Label>
|
||||
|
||||
<TextArea
|
||||
data-testid="cancel_reason"
|
||||
@@ -267,63 +334,8 @@ export default function CancelBooking(props: Props) {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="confirm_cancel"
|
||||
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
// telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
|
||||
const { csrfToken } = await response.json();
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify({
|
||||
uid: booking?.uid,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
// @NOTE: very important this shouldn't cancel with number ID use uid instead
|
||||
seatReferenceUid,
|
||||
cancelledBy: currentUserEmail,
|
||||
internalNote: internalNote,
|
||||
csrfToken,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const bookingWithCancellationReason = {
|
||||
...(bookingCancelledEventProps.booking as object),
|
||||
cancellationReason,
|
||||
} as unknown;
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
// tested by apps/web/playwright/booking-pages.e2e.ts
|
||||
sdkActionManager?.fire("bookingCancelled", {
|
||||
...bookingCancelledEventProps,
|
||||
booking: bookingWithCancellationReason,
|
||||
});
|
||||
refreshData();
|
||||
if (props.onCanceled) {
|
||||
props.onCanceled();
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setLoading(false);
|
||||
const errorMessage =
|
||||
data.message ||
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`;
|
||||
|
||||
if (props.showErrorAsToast) {
|
||||
showToast(errorMessage, "error");
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!canCancel}
|
||||
onClick={handleCancel}
|
||||
loading={loading}>
|
||||
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
@@ -48,6 +49,7 @@ interface ICancelBookingDialog {
|
||||
isHost: boolean;
|
||||
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
requiresCancellationReason?: CancellationReasonRequirement | null;
|
||||
}
|
||||
|
||||
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
||||
@@ -67,6 +69,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
||||
isHost,
|
||||
internalNotePresets = [],
|
||||
eventTypeMetadata,
|
||||
requiresCancellationReason,
|
||||
} = props;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -113,6 +116,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
||||
isHost={isHost}
|
||||
internalNotePresets={internalNotePresets}
|
||||
eventTypeMetadata={eventTypeMetadata}
|
||||
requiresCancellationReason={requiresCancellationReason}
|
||||
showErrorAsToast={true}
|
||||
onCanceled={handleCanceled}
|
||||
/>
|
||||
|
||||
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
hideOrganizerEmail: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
requiresCancellationReason: true,
|
||||
minimumRescheduleNotice: true,
|
||||
disableGuests: true,
|
||||
timeZone: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { getBookingResponse } from "./getBooking";
|
||||
|
||||
@@ -54,8 +54,9 @@ export const triggerRecordingReadyWebhook = async ({
|
||||
})
|
||||
);
|
||||
|
||||
const { assignmentReason: _emailAssignmentReason, ...evtWithoutAssignmentReason } = evt;
|
||||
const payload: EventPayloadType = {
|
||||
...evt,
|
||||
...evtWithoutAssignmentReason,
|
||||
downloadLink,
|
||||
};
|
||||
|
||||
@@ -94,8 +95,9 @@ export const triggerTranscriptionGeneratedWebhook = async ({
|
||||
})
|
||||
);
|
||||
|
||||
const { assignmentReason: _emailAssignmentReason2, ...evtWithoutAssignmentReason2 } = evt;
|
||||
const payload: EventPayloadType = {
|
||||
...evt,
|
||||
...evtWithoutAssignmentReason2,
|
||||
downloadLinks,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -10,6 +9,8 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { FilterSearchField, Select } from "@calcom/ui/components/form";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -28,6 +29,17 @@ type TranslationWithParams = {
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
type DisplayFieldValue =
|
||||
| { type: "translationKey"; valueKey: string }
|
||||
| { type: "rawValue"; value: string }
|
||||
| { type: "rawValues"; values: string[] }
|
||||
| { type: "translationsWithParams"; valuesWithParams: TranslationWithParams[] };
|
||||
|
||||
type DisplayField = {
|
||||
labelKey: string;
|
||||
fieldValue: DisplayFieldValue;
|
||||
};
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
@@ -36,7 +48,7 @@ type AuditLog = {
|
||||
source: string;
|
||||
displayJson?: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
|
||||
displayFields?: DisplayField[] | null;
|
||||
actor: {
|
||||
type: AuditActorType;
|
||||
displayName: string | null;
|
||||
@@ -139,7 +151,13 @@ function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWi
|
||||
values={actionDisplayTitle.params}
|
||||
components={actionDisplayTitle.components.map((comp) =>
|
||||
comp.type === "link" ? (
|
||||
<Link key={comp.href} href={comp.href} className="text-emphasis underline hover:no-underline" />
|
||||
<Link
|
||||
key={comp.href}
|
||||
href={comp.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-emphasis underline hover:no-underline"
|
||||
/>
|
||||
) : (
|
||||
<span key={comp.href} />
|
||||
)
|
||||
@@ -181,30 +199,39 @@ function JsonViewer({ data }: JsonViewerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DisplayFieldValueProps {
|
||||
field: {
|
||||
valueKey?: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
};
|
||||
interface DisplayFieldValueComponentProps {
|
||||
fieldValue: DisplayFieldValue;
|
||||
}
|
||||
|
||||
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
|
||||
function DisplayFieldValueComponent({ fieldValue }: DisplayFieldValueComponentProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (field.values) {
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
{field.values.map((v, i) => (
|
||||
<span className="p-0.5" key={i}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
switch (fieldValue.type) {
|
||||
case "translationsWithParams":
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
{fieldValue.valuesWithParams.map((v, i) => (
|
||||
<span className="p-0.5" key={i}>
|
||||
{t(v.key, v.params)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
case "rawValues":
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
{fieldValue.values.map((v, i) => (
|
||||
<span className="p-0.5" key={i}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
case "rawValue":
|
||||
return <>{fieldValue.value}</>;
|
||||
case "translationKey":
|
||||
return <>{t(fieldValue.valueKey)}</>;
|
||||
}
|
||||
|
||||
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
@@ -280,7 +307,9 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
{actorRole && <span>{` (${t(actorRole)})`}</span>}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(log.timestamp).fromNow()}</span>
|
||||
<Tooltip content={format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}>
|
||||
<span>{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,15 +333,15 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
{/* Render displayFields if available, otherwise show type */}
|
||||
{log.displayFields && log.displayFields.length > 0
|
||||
? log.displayFields.map((field, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
||||
<span className="font-medium">
|
||||
<DisplayFieldValue field={field} />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
||||
<span className="font-medium">
|
||||
<DisplayFieldValueComponent fieldValue={field.fieldValue} />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t("actor")}</span>
|
||||
@@ -337,7 +366,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t("timestamp")}</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
{format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
{log.displayJson && Object.keys(log.displayJson).length > 0 && (
|
||||
@@ -368,28 +397,44 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
}
|
||||
|
||||
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const doesMatchDisplayFields = (): boolean => {
|
||||
return (
|
||||
log.displayFields?.some((field) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
field.valueKey?.toLowerCase().includes(searchLower) ||
|
||||
field.labelKey?.toLowerCase().includes(searchLower) ||
|
||||
field.value?.toLowerCase().includes(searchLower) ||
|
||||
field.values?.some((v) => v.toLowerCase().includes(searchLower))
|
||||
);
|
||||
const translatedLabel = field.labelKey ? t(field.labelKey) : "";
|
||||
if (translatedLabel.toLowerCase().includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
const { fieldValue } = field;
|
||||
switch (fieldValue.type) {
|
||||
case "translationKey":
|
||||
return t(fieldValue.valueKey).toLowerCase().includes(searchLower);
|
||||
case "rawValue":
|
||||
return fieldValue.value.toLowerCase().includes(searchLower);
|
||||
case "rawValues":
|
||||
return fieldValue.values.some((v) => v.toLowerCase().includes(searchLower));
|
||||
case "translationsWithParams":
|
||||
return fieldValue.valuesWithParams.some(
|
||||
(v) =>
|
||||
t(v.key, v.params).toLowerCase().includes(searchLower) ||
|
||||
Object.values(v.params ?? {}).some((param) =>
|
||||
param?.toString().toLowerCase().includes(searchLower)
|
||||
)
|
||||
);
|
||||
}
|
||||
}) ?? false
|
||||
);
|
||||
};
|
||||
|
||||
const doesMatchActionDisplayTitle = (): boolean => {
|
||||
return (
|
||||
log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) =>
|
||||
param?.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
const translatedTitle = log.actionDisplayTitle.key
|
||||
? t(log.actionDisplayTitle.key, log.actionDisplayTitle.params ?? {})
|
||||
: "";
|
||||
|
||||
return translatedTitle.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
};
|
||||
|
||||
const matchesSearch =
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store
|
||||
import { useEvent, useScheduleForEvent } from "@calcom/web/modules/schedules/hooks/useEvent";
|
||||
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
|
||||
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -42,11 +42,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen
|
||||
});
|
||||
const event = props.eventData
|
||||
? {
|
||||
data: props.eventData,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
}
|
||||
data: props.eventData,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
}
|
||||
: clientFetchedEvent;
|
||||
|
||||
const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts);
|
||||
@@ -126,6 +126,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen
|
||||
name: bookerForm.formName,
|
||||
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
|
||||
onVerifyEmail: bookerForm.beforeVerifyEmail,
|
||||
eventTypeId: event?.data?.id,
|
||||
});
|
||||
const slots = useSlots(event?.data ? { id: event.data.id, length: event.data.length } : null);
|
||||
|
||||
@@ -149,6 +150,9 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen
|
||||
useApiV2: props.useApiV2,
|
||||
bookerLayout,
|
||||
...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}),
|
||||
restrictionSchedule: event.data?.restrictionScheduleId
|
||||
? { id: event.data.restrictionScheduleId, useBookerTimezone: event.data.useBookerTimezone }
|
||||
: undefined,
|
||||
});
|
||||
const bookings = useBookings({
|
||||
event,
|
||||
|
||||
@@ -4,7 +4,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
|
||||
import { useBanners } from "@calcom/web/modules/shell/banners/useBanners";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore, useStore } from "zustand";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
type BookingDetailsSheetStore = {
|
||||
selectedBookingUid: string | null;
|
||||
bookings: BookingOutput[];
|
||||
setSelectedBookingUid: (uid: string | null) => void;
|
||||
setBookings: (bookings: BookingOutput[]) => void;
|
||||
getSelectedBooking: () => BookingOutput | null;
|
||||
};
|
||||
|
||||
function createTestStore(initialBookings: BookingOutput[] = []) {
|
||||
return createStore<BookingDetailsSheetStore>((set, get) => ({
|
||||
selectedBookingUid: null,
|
||||
bookings: initialBookings,
|
||||
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
|
||||
setBookings: (bookings) => set({ bookings }),
|
||||
getSelectedBooking: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const makeBooking = (uid: string, title: string): BookingOutput =>
|
||||
({
|
||||
uid,
|
||||
id: Number(uid),
|
||||
title,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
status: "ACCEPTED",
|
||||
attendees: [],
|
||||
metadata: null,
|
||||
location: null,
|
||||
rescheduled: false,
|
||||
recurringEventId: null,
|
||||
fromReschedule: null,
|
||||
responses: {},
|
||||
payment: [],
|
||||
eventType: null,
|
||||
user: null,
|
||||
assignmentReasonSortedByCreatedAt: [],
|
||||
}) as unknown as BookingOutput;
|
||||
|
||||
describe("BookingDetailsSheet transition behavior", () => {
|
||||
const bookingA = makeBooking("uid-a", "Booking A");
|
||||
const bookingB = makeBooking("uid-b", "Booking B");
|
||||
|
||||
it("getSelectedBooking returns null when no uid is selected", () => {
|
||||
const store = createTestStore([bookingA, bookingB]);
|
||||
expect(store.getState().getSelectedBooking()).toBeNull();
|
||||
});
|
||||
|
||||
it("getSelectedBooking returns the correct booking when uid is set", () => {
|
||||
const store = createTestStore([bookingA, bookingB]);
|
||||
store.getState().setSelectedBookingUid("uid-a");
|
||||
expect(store.getState().getSelectedBooking()?.uid).toBe("uid-a");
|
||||
});
|
||||
|
||||
it("getSelectedBooking returns null when uid does not match any booking", () => {
|
||||
const store = createTestStore([bookingA, bookingB]);
|
||||
store.getState().setSelectedBookingUid("uid-nonexistent");
|
||||
expect(store.getState().getSelectedBooking()).toBeNull();
|
||||
});
|
||||
|
||||
it("switching between bookings updates getSelectedBooking immediately", () => {
|
||||
const store = createTestStore([bookingA, bookingB]);
|
||||
store.getState().setSelectedBookingUid("uid-a");
|
||||
expect(store.getState().getSelectedBooking()?.title).toBe("Booking A");
|
||||
|
||||
store.getState().setSelectedBookingUid("uid-b");
|
||||
expect(store.getState().getSelectedBooking()?.title).toBe("Booking B");
|
||||
});
|
||||
|
||||
describe("lastBookingRef fallback logic", () => {
|
||||
it("preserves last booking when getSelectedBooking returns null during transition", () => {
|
||||
let lastBooking: BookingOutput | null = null;
|
||||
let selectedBookingUid: string | null = null;
|
||||
|
||||
const store = createTestStore([bookingA, bookingB]);
|
||||
store.getState().setSelectedBookingUid("uid-a");
|
||||
|
||||
const booking = store.getState().getSelectedBooking();
|
||||
if (booking) {
|
||||
lastBooking = booking;
|
||||
}
|
||||
|
||||
store.getState().setSelectedBookingUid("uid-nonexistent");
|
||||
selectedBookingUid = store.getState().selectedBookingUid;
|
||||
|
||||
const currentBooking = store.getState().getSelectedBooking();
|
||||
const displayBooking = currentBooking ?? lastBooking;
|
||||
|
||||
expect(currentBooking).toBeNull();
|
||||
expect(selectedBookingUid).toBe("uid-nonexistent");
|
||||
expect(displayBooking?.uid).toBe("uid-a");
|
||||
});
|
||||
|
||||
it("clears lastBooking when selectedBookingUid is explicitly null", () => {
|
||||
let lastBooking: BookingOutput | null = null;
|
||||
|
||||
const store = createTestStore([bookingA]);
|
||||
store.getState().setSelectedBookingUid("uid-a");
|
||||
|
||||
const booking = store.getState().getSelectedBooking();
|
||||
if (booking) {
|
||||
lastBooking = booking;
|
||||
}
|
||||
|
||||
store.getState().setSelectedBookingUid(null);
|
||||
const selectedBookingUid = store.getState().selectedBookingUid;
|
||||
|
||||
if (!selectedBookingUid) {
|
||||
lastBooking = null;
|
||||
}
|
||||
|
||||
const currentBooking = store.getState().getSelectedBooking();
|
||||
const displayBooking = currentBooking ?? lastBooking;
|
||||
|
||||
expect(displayBooking).toBeNull();
|
||||
});
|
||||
|
||||
it("updates lastBooking when new booking is found", () => {
|
||||
let lastBooking: BookingOutput | null = null;
|
||||
|
||||
const store = createTestStore([bookingA, bookingB]);
|
||||
|
||||
store.getState().setSelectedBookingUid("uid-a");
|
||||
const bookingAResult = store.getState().getSelectedBooking();
|
||||
if (bookingAResult) lastBooking = bookingAResult;
|
||||
expect(lastBooking?.uid).toBe("uid-a");
|
||||
|
||||
store.getState().setSelectedBookingUid("uid-b");
|
||||
const bookingBResult = store.getState().getSelectedBooking();
|
||||
if (bookingBResult) lastBooking = bookingBResult;
|
||||
expect(lastBooking?.uid).toBe("uid-b");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { useBookingLocation } from "@calcom/features/bookings/hooks";
|
||||
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
|
||||
import { formatPrice } from "@calcom/lib/currencyConversions";
|
||||
import { formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -29,11 +30,12 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons";
|
||||
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { z } from "zod";
|
||||
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
@@ -43,6 +45,10 @@ import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
import {
|
||||
checkSheetActive,
|
||||
createBookingSheetKeydownHandler,
|
||||
} from "../lib/bookingSheetKeyboardHandler";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
|
||||
@@ -63,14 +69,25 @@ export function BookingDetailsSheet({
|
||||
bookingAuditEnabled = false,
|
||||
}: BookingDetailsSheetProps) {
|
||||
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
|
||||
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
||||
const lastBookingRef = useRef<BookingOutput | null>(null);
|
||||
|
||||
// Return null if no booking is selected (sheet is closed)
|
||||
if (!booking) return null;
|
||||
if (booking) {
|
||||
lastBookingRef.current = booking;
|
||||
}
|
||||
|
||||
if (!selectedBookingUid) {
|
||||
lastBookingRef.current = null;
|
||||
}
|
||||
|
||||
const displayBooking = booking ?? lastBookingRef.current;
|
||||
|
||||
if (!displayBooking) return null;
|
||||
|
||||
return (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingDetailsSheetInner
|
||||
booking={booking}
|
||||
booking={displayBooking}
|
||||
userTimeZone={userTimeZone}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userId={userId}
|
||||
@@ -91,10 +108,9 @@ interface BookingDetailsSheetInnerProps {
|
||||
}
|
||||
|
||||
function useActiveSegment(bookingAuditEnabled: boolean) {
|
||||
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore((state) => [
|
||||
state.activeSegment,
|
||||
state.setActiveSegment,
|
||||
]);
|
||||
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore(
|
||||
(state) => [state.activeSegment, state.setActiveSegment]
|
||||
);
|
||||
|
||||
const getDerivedActiveSegment = ({
|
||||
activeSegment,
|
||||
@@ -109,10 +125,15 @@ function useActiveSegment(bookingAuditEnabled: boolean) {
|
||||
return activeSegment ?? "info";
|
||||
};
|
||||
|
||||
const derivedActiveSegment = getDerivedActiveSegment({ activeSegment, bookingAuditEnabled });
|
||||
const derivedActiveSegment = getDerivedActiveSegment({
|
||||
activeSegment,
|
||||
bookingAuditEnabled,
|
||||
});
|
||||
|
||||
const setDerivedActiveSegment = (segment: "info" | "history") => {
|
||||
setActiveSegmentInStore(getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled }));
|
||||
setActiveSegmentInStore(
|
||||
getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled })
|
||||
);
|
||||
};
|
||||
|
||||
return [derivedActiveSegment, setDerivedActiveSegment] as const;
|
||||
@@ -127,16 +148,18 @@ function BookingDetailsSheetInner({
|
||||
bookingAuditEnabled = false,
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const [activeSegment, setActiveSegment] = useActiveSegment(bookingAuditEnabled);
|
||||
const [activeSegment, setActiveSegment] =
|
||||
useActiveSegment(bookingAuditEnabled);
|
||||
|
||||
// Fetch additional booking details for reschedule information
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
const { data: bookingDetails } =
|
||||
trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// Get navigation state from the store in a single selector
|
||||
const navigation = useBookingDetailsSheetStore((state) => {
|
||||
@@ -151,23 +174,57 @@ function BookingDetailsSheetInner({
|
||||
isTransitioning: state.isTransitioning,
|
||||
setSelectedBookingUid: state.setSelectedBookingUid,
|
||||
setActiveSegment: state.setActiveSegment,
|
||||
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
|
||||
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
|
||||
canGoNext:
|
||||
hasNextInArray ||
|
||||
(isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
|
||||
canGoPrev:
|
||||
hasPreviousInArray ||
|
||||
(isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
|
||||
};
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = useCallback(() => {
|
||||
navigation.setSelectedBookingUid(null);
|
||||
navigation.setActiveSegment(null);
|
||||
};
|
||||
}, [navigation.setSelectedBookingUid, navigation.setActiveSegment]);
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = useCallback(() => {
|
||||
navigation.navigateNext();
|
||||
};
|
||||
}, [navigation.navigateNext]);
|
||||
|
||||
const handlePrevious = () => {
|
||||
const handlePrevious = useCallback(() => {
|
||||
navigation.navigatePrevious();
|
||||
};
|
||||
}, [navigation.navigatePrevious]);
|
||||
|
||||
const joinButtonWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const sheetContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isSheetActive = useCallback(
|
||||
() => checkSheetActive(sheetContentRef.current, document.activeElement),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = createBookingSheetKeydownHandler({
|
||||
isSheetActive,
|
||||
canGoPrev: navigation.canGoPrev,
|
||||
canGoNext: navigation.canGoNext,
|
||||
isTransitioning: navigation.isTransitioning,
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
getJoinLink: () => joinButtonWrapperRef.current?.querySelector("a"),
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
navigation.canGoPrev,
|
||||
navigation.canGoNext,
|
||||
navigation.isTransitioning,
|
||||
handleNext,
|
||||
handlePrevious,
|
||||
isSheetActive,
|
||||
]);
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
const endTime = dayjs(booking.endTime).tz(userTimeZone);
|
||||
@@ -196,22 +253,20 @@ function BookingDetailsSheetInner({
|
||||
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(
|
||||
booking.metadata ?? null
|
||||
);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
|
||||
const recurringInfo =
|
||||
booking.recurringEventId && booking.eventType?.recurringEvent
|
||||
? {
|
||||
count: booking.eventType.recurringEvent.count,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
}
|
||||
count: booking.eventType.recurringEvent.count,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
}
|
||||
: null;
|
||||
|
||||
const customResponses = booking.responses
|
||||
? Object.entries(booking.responses as Record<string, unknown>)
|
||||
.filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName))
|
||||
.map(([question, answer]) => [question, answer] as [string, unknown])
|
||||
: [];
|
||||
const responses = (booking.responses ?? {}) as Record<string, unknown>;
|
||||
|
||||
const reason =
|
||||
booking.assignmentReasonSortedByCreatedAt?.[booking.assignmentReasonSortedByCreatedAt.length - 1];
|
||||
@@ -220,19 +275,17 @@ function BookingDetailsSheetInner({
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleClose} modal={false}>
|
||||
<SheetContent
|
||||
ref={sheetContentRef}
|
||||
className="overflow-y-auto pb-0 sm:pb-0"
|
||||
hideOverlay
|
||||
onInteractOutside={(e) => {
|
||||
// Check if the click is on a booking list item
|
||||
const target = e.target as HTMLElement;
|
||||
const isBookingListItem = target.closest("[data-booking-list-item]");
|
||||
const isBookingItem =
|
||||
target.closest("[data-booking-list-item]") || target.closest("[data-booking-calendar-event]");
|
||||
|
||||
if (isBookingListItem) {
|
||||
// Prevent closing when clicking a booking list item
|
||||
// The item's onClick will handle opening the sheet with the new booking
|
||||
if (isBookingItem) {
|
||||
e.preventDefault();
|
||||
}
|
||||
// If clicking elsewhere, allow the default behavior (close the sheet)
|
||||
}}>
|
||||
<SheetHeader showCloseButton={false} className="mt-0 w-full">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
@@ -245,38 +298,65 @@ function BookingDetailsSheetInner({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("previous_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
tabIndex={-1}
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("next_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
tabIndex={-1}
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("close_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
@@ -284,9 +364,9 @@ function BookingDetailsSheetInner({
|
||||
<SheetBody className="-mt-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
|
||||
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
|
||||
<span>{booking.title}</span>
|
||||
<SheetTitle className="flex items-center gap-3 font-semibold text-emphasis text-xl">
|
||||
<div className="w-0.5 shrink-0 self-stretch rounded-lg bg-emphasis"></div>
|
||||
<span data-testid="booking-sheet-title">{booking.title}</span>
|
||||
</SheetTitle>
|
||||
</div>
|
||||
|
||||
@@ -328,14 +408,19 @@ function BookingDetailsSheetInner({
|
||||
|
||||
<AssignmentReasonSection booking={booking} />
|
||||
|
||||
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
|
||||
{booking.payment?.[0] && (
|
||||
<PaymentSection
|
||||
booking={booking}
|
||||
payment={booking.payment[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SlotsSection booking={booking} />
|
||||
|
||||
<AdditionalNotesSection booking={booking} />
|
||||
|
||||
<CustomQuestionsSection
|
||||
customResponses={customResponses}
|
||||
responses={responses}
|
||||
bookingFields={booking.eventType?.bookingFields}
|
||||
/>
|
||||
|
||||
@@ -368,18 +453,29 @@ function BookingDetailsSheetInner({
|
||||
</>
|
||||
) : (
|
||||
!booking.rescheduled && (
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("join_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div ref={joinButtonWrapperRef}>
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
listingStatus:
|
||||
booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
@@ -417,7 +513,7 @@ function WhenSection({
|
||||
return (
|
||||
<Section title={t("when")}>
|
||||
{previousBooking?.startTime && previousBooking?.endTime && (
|
||||
<div className="text-default flex flex-col text-sm line-through opacity-60">
|
||||
<div className="flex flex-col text-default text-sm line-through opacity-60">
|
||||
<DisplayTimestamp
|
||||
startTime={previousBooking.startTime}
|
||||
endTime={previousBooking.endTime}
|
||||
@@ -427,10 +523,15 @@ function WhenSection({
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis flex flex-col text-sm font-medium",
|
||||
"flex flex-col font-medium text-emphasis text-sm",
|
||||
rescheduled && "line-through"
|
||||
)}>
|
||||
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
|
||||
)}
|
||||
>
|
||||
<DisplayTimestamp
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -445,14 +546,20 @@ function DisplayTimestamp({
|
||||
endTime: Date | dayjs.Dayjs;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const {
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
|
||||
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
|
||||
const localizedTimezone = timeZone
|
||||
? (formatToLocalizedTimezone(start, language, timeZone) ?? timeZone)
|
||||
: start.format("Z");
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>{start.format("dddd, MMMM D, YYYY")}</span>
|
||||
<span>
|
||||
{start.format("h:mma")} - {end.format("h:mma")} ({timeZone || start.format("Z")})
|
||||
{start.format("h:mma")} - {end.format("h:mma")} ({localizedTimezone})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -470,13 +577,16 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
imageSrc={
|
||||
booking.user.avatarUrl
|
||||
? getUserAvatarUrl(booking.user)
|
||||
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
|
||||
: getPlaceholderAvatar(
|
||||
null,
|
||||
booking.user.name || booking.user.email
|
||||
)
|
||||
}
|
||||
alt={booking.user.name || booking.user.email || ""}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">
|
||||
<p className="truncate text-emphasis text-sm leading-[1.2]">
|
||||
{booking.eventType?.hideOrganizerEmail
|
||||
? booking.user.name || t("organizer")
|
||||
: booking.user.name || booking.user.email}
|
||||
@@ -486,14 +596,20 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
</Badge>
|
||||
</div>
|
||||
{!booking.eventType?.hideOrganizerEmail && (
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
|
||||
<p className="truncate text-default text-sm leading-[1.2]">
|
||||
{booking.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.map((attendee, idx) => {
|
||||
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
|
||||
const name =
|
||||
attendee.user?.name ||
|
||||
attendee.name ||
|
||||
attendee.user?.email ||
|
||||
attendee.email;
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar
|
||||
@@ -507,6 +623,9 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">{name}</p>
|
||||
{attendee.phoneNumber && (
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{attendee.phoneNumber}</p>
|
||||
)}
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -517,7 +636,13 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
|
||||
function WhereSection({
|
||||
booking,
|
||||
meta,
|
||||
}: {
|
||||
booking: BookingOutput;
|
||||
meta: BookingMetaData | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
|
||||
@@ -555,12 +680,15 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
|
||||
<span className="shrink-0 font-medium text-emphasis">
|
||||
{provider?.label}:
|
||||
</span>
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-600 hover:underline">
|
||||
className="truncate text-blue-600 hover:underline"
|
||||
>
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
</div>
|
||||
@@ -582,7 +710,7 @@ function RecurringInfoSection({
|
||||
|
||||
return (
|
||||
<Section title={t("recurring_event")}>
|
||||
<p className="text-emphasis text-sm font-medium">
|
||||
<p className="font-medium text-emphasis text-sm">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: recurringInfo.recurringEvent,
|
||||
@@ -596,19 +724,26 @@ function RecurringInfoSection({
|
||||
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.assignmentReasonSortedByCreatedAt || booking.assignmentReasonSortedByCreatedAt.length === 0) {
|
||||
if (
|
||||
!booking.assignmentReasonSortedByCreatedAt ||
|
||||
booking.assignmentReasonSortedByCreatedAt.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reason =
|
||||
booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1];
|
||||
booking.assignmentReasonSortedByCreatedAt[
|
||||
booking.assignmentReasonSortedByCreatedAt.length - 1
|
||||
];
|
||||
if (!reason.reasonString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("assignment_reason")}>
|
||||
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
|
||||
<div className="font-medium text-emphasis text-sm">
|
||||
{reason.reasonString}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -626,7 +761,9 @@ function PaymentSection({
|
||||
const parsedEventTypeMetadata = booking.eventType?.metadata
|
||||
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
|
||||
: null;
|
||||
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
|
||||
const eventTypeMetadata = parsedEventTypeMetadata?.success
|
||||
? parsedEventTypeMetadata.data
|
||||
: null;
|
||||
|
||||
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
|
||||
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
|
||||
@@ -645,8 +782,10 @@ function PaymentSection({
|
||||
|
||||
return (
|
||||
<Section title={t("payment")}>
|
||||
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
|
||||
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
|
||||
<p className="font-medium text-emphasis text-sm">{formattedPrice}</p>
|
||||
{paymentStatusMessage && (
|
||||
<p className="text-subtle text-xs">{paymentStatusMessage}</p>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -667,61 +806,49 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
|
||||
|
||||
return (
|
||||
<Section title={t("slots")}>
|
||||
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
<p className="font-medium text-emphasis text-sm">
|
||||
{t("slots_taken", { takenSeats, totalSeats })}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomQuestionsSection({
|
||||
customResponses,
|
||||
responses,
|
||||
bookingFields: rawBookingFields,
|
||||
}: {
|
||||
customResponses: [string, unknown][];
|
||||
responses: Record<string, unknown>;
|
||||
bookingFields?: unknown;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Parse and memoize booking fields
|
||||
const bookingFields = useMemo(() => {
|
||||
if (!rawBookingFields) return undefined;
|
||||
const parsed = eventTypeBookingFields.safeParse(rawBookingFields);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}, [rawBookingFields]);
|
||||
|
||||
// Filter out responses with falsy answers or empty arrays
|
||||
const validResponses = useMemo(
|
||||
(): [string, unknown][] =>
|
||||
customResponses.filter(([, answer]) => {
|
||||
if (!answer) return false;
|
||||
if (Array.isArray(answer) && answer.length === 0) return false;
|
||||
return true;
|
||||
}),
|
||||
[customResponses]
|
||||
);
|
||||
|
||||
// Memoize field label lookups
|
||||
const fieldLabels = useMemo(() => {
|
||||
if (!bookingFields) return new Map<string, string>();
|
||||
|
||||
return new Map<string, string>(
|
||||
bookingFields.map(
|
||||
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
|
||||
)
|
||||
);
|
||||
}, [bookingFields, t]);
|
||||
|
||||
if (validResponses.length === 0) {
|
||||
if (!bookingFields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{validResponses.map(([fieldName, answer], idx) => {
|
||||
const fieldNameStr = String(fieldName);
|
||||
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
|
||||
{bookingFields.map((field) => {
|
||||
if (!field) return null;
|
||||
if (!shouldShowFieldInCustomResponses(field.name)) return null;
|
||||
|
||||
const answer = responses[field.name];
|
||||
if (!answer) return null;
|
||||
if (Array.isArray(answer) && answer.length === 0) return null;
|
||||
|
||||
const label = field.label || t(field.defaultLabel || field.name);
|
||||
|
||||
return (
|
||||
<Section key={idx} title={title}>
|
||||
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
|
||||
<Section key={field.name} title={label}>
|
||||
<p className="font-medium text-emphasis text-sm">
|
||||
{field.type === "boolean" ? (answer ? t("yes") : t("no")) : String(answer)}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
@@ -744,7 +871,8 @@ function OldRescheduledBookingInfo({
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancellationReason =
|
||||
booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduledBy;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
@@ -753,7 +881,7 @@ function OldRescheduledBookingInfo({
|
||||
{rescheduledToBooking?.uid && (
|
||||
<Section title={t("rescheduled")}>
|
||||
<Link href={`/booking/${rescheduledToBooking.uid}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
<div className="flex items-center gap-1 text-default text-sm underline">
|
||||
{t("view_booking")}
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -762,17 +890,19 @@ function OldRescheduledBookingInfo({
|
||||
)}
|
||||
{rescheduledBy && (
|
||||
<Section title={t("rescheduled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
|
||||
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{cancellationReason}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -788,15 +918,18 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancellationReason =
|
||||
booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduler;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t("rescheduled_by")}>
|
||||
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
|
||||
{rescheduledBy && (
|
||||
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
|
||||
)}
|
||||
<Link href={`/booking/${booking.fromReschedule}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
<div className="flex items-center gap-1 text-default text-sm underline">
|
||||
{t("original_booking")}
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -804,7 +937,9 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
</Section>
|
||||
{cancellationReason && (
|
||||
<Section title={t("reschedule_reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{cancellationReason}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -816,14 +951,17 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for cancelled/rejected bookings that were NOT rescheduled
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
const isCancelled =
|
||||
booking.status === BookingStatus.CANCELLED ||
|
||||
booking.status === BookingStatus.REJECTED;
|
||||
const wasRescheduled = booking.rescheduled === true;
|
||||
|
||||
if (!isCancelled || wasRescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancellationReason =
|
||||
booking.cancellationReason || booking.rejectionReason;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
if (!cancellationReason && !cancelledBy) {
|
||||
@@ -834,12 +972,14 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
<>
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{cancellationReason}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -855,7 +995,9 @@ function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
|
||||
|
||||
return (
|
||||
<Section title={t("additional_notes")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{booking.description}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -884,7 +1026,9 @@ function BookingHeaderBadges({
|
||||
{reasonTitle}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{booking.eventType.team && (
|
||||
<Badge variant="gray">{booking.eventType.team.name}</Badge>
|
||||
)}
|
||||
{booking.paid && !payment ? (
|
||||
<Badge variant="orange">{t("error_collecting_card")}</Badge>
|
||||
) : booking.paid ? (
|
||||
@@ -919,7 +1063,9 @@ function TrackingSection({
|
||||
return null;
|
||||
}
|
||||
|
||||
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
|
||||
const utmEntries = Object.entries(tracking).filter(([_, value]) =>
|
||||
Boolean(value)
|
||||
);
|
||||
|
||||
if (utmEntries.length === 0) {
|
||||
return null;
|
||||
@@ -931,7 +1077,9 @@ function TrackingSection({
|
||||
{utmEntries.map(([key, value]) => (
|
||||
<div key={key} className="mb-1 last:mb-0">
|
||||
<span className="font-medium">{key}</span>:{" "}
|
||||
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
|
||||
<code className="rounded bg-subtle px-1 py-0.5 font-mono text-default text-xs">
|
||||
{value}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -950,7 +1098,7 @@ function Section({
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("flex flex-col gap-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-medium">{title}</h3>
|
||||
<h3 className="font-medium text-subtle text-xs">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,13 +13,14 @@ import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
|
||||
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
|
||||
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
|
||||
import { useSwitchToCorrectStatusTab } from "~/bookings/hooks/useSwitchToCorrectStatusTab";
|
||||
import { DataTableFilters, DataTableSegment } from "~/data-table/components";
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
@@ -231,17 +232,21 @@ function BookingListInner({
|
||||
}
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset, setPageIndex, isValidatorPending } = useDataTable();
|
||||
const { limit, offset, isValidatorPending } = useDataTable();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
const { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking } = useSwitchToCorrectStatusTab({
|
||||
defaultStatus: props.status,
|
||||
});
|
||||
|
||||
// Build query input once - shared between query and prefetching
|
||||
const queryInput = useMemo(
|
||||
() => ({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
statuses: [props.status],
|
||||
statuses: [resolvedTabStatus],
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
@@ -257,7 +262,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
[
|
||||
limit,
|
||||
offset,
|
||||
props.status,
|
||||
resolvedTabStatus,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
@@ -271,10 +276,19 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const query = trpc.viewer.bookings.get.useQuery(queryInput, {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
enabled: !isValidatorPending, // Wait for validator to be ready before fetching
|
||||
// We wait for tab status to be resolved before fetching, so that we can fetch the correct bookings as per resolved tab status
|
||||
enabled: !isValidatorPending && !isResolvingTabStatus, // Wait for validator to be ready before fetching
|
||||
});
|
||||
|
||||
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
|
||||
const bookings = useMemo(() => {
|
||||
const queryBookings = query.data?.bookings ?? [];
|
||||
if (!preSelectedBooking) return queryBookings;
|
||||
if (queryBookings.some((b) => b.uid === preSelectedBooking.uid)) return queryBookings;
|
||||
// It ensures that the drawer opens for a booking that isn't even in the bookings list
|
||||
// Note that, bookings list doesn't use this so, it won't be visible in the list view but drawer will open for it
|
||||
// We don't want to show this booking in the list view, as it might not match the filters/pagination applied
|
||||
return [...queryBookings, preSelectedBooking];
|
||||
}, [query.data?.bookings, preSelectedBooking]);
|
||||
|
||||
// Always call the hook and provide navigation capabilities
|
||||
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
|
||||
@@ -290,6 +304,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
status={resolvedTabStatus}
|
||||
data={query.data}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { useJoinableLocation } from "./useJoinableLocation";
|
||||
|
||||
interface JoinMeetingButtonProps {
|
||||
@@ -54,9 +53,15 @@ export function JoinMeetingButton({
|
||||
onClick={handleClick}>
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={provider.iconUrl} className="h-4 w-4 shrink-0 rounded-sm" alt={`${provider.label} logo`} />
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider.label })
|
||||
: t("join_meeting")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
import { useOverlayCalendarStore } from "@calcom/features/bookings/Booker/components/OverlayCalendar/store";
|
||||
import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots";
|
||||
import { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime";
|
||||
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import type { useScheduleForEventReturnType } from "@calcom/web/modules/schedules/hooks/useEvent";
|
||||
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import { useOverlayCalendarStore } from "@calcom/features/bookings/Booker/components/OverlayCalendar/store";
|
||||
|
||||
import { OutOfOfficeInSlots } from "./OutOfOfficeInSlots";
|
||||
|
||||
export const LargeCalendar = ({
|
||||
extraDays,
|
||||
@@ -78,6 +80,7 @@ export const LargeCalendar = ({
|
||||
hoverEventDuration={eventDuration}
|
||||
hideHeader
|
||||
timezone={timezone}
|
||||
renderOutOfOffice={(props) => <OutOfOfficeInSlots {...props} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getTabForBooking } from "./useSwitchToCorrectStatusTab";
|
||||
|
||||
function makeBooking(overrides: {
|
||||
status: string;
|
||||
endTime: Date;
|
||||
recurringEventId?: string | null;
|
||||
}){
|
||||
return {
|
||||
status: overrides.status,
|
||||
endTime: overrides.endTime,
|
||||
recurringEventId: overrides.recurringEventId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const future = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
describe("getTabForBooking", () => {
|
||||
it("returns 'cancelled' for CANCELLED bookings", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "CANCELLED", endTime: future }))).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'cancelled' for REJECTED bookings", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "REJECTED", endTime: future }))).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'cancelled' for CANCELLED bookings even if past", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "CANCELLED", endTime: past }))).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'unconfirmed' for PENDING bookings in the future", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "PENDING", endTime: future }))).toBe("unconfirmed");
|
||||
});
|
||||
|
||||
it("returns 'past' for PENDING bookings that have ended", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "PENDING", endTime: past }))).toBe("past");
|
||||
});
|
||||
|
||||
it("returns 'past' for ACCEPTED bookings that have ended", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: past }))).toBe("past");
|
||||
});
|
||||
|
||||
it("returns 'recurring' for future ACCEPTED bookings with recurringEventId", () => {
|
||||
expect(
|
||||
getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: future, recurringEventId: "rec-123" }))
|
||||
).toBe("recurring");
|
||||
});
|
||||
|
||||
it("returns 'upcoming' for future ACCEPTED bookings without recurringEventId", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: future }))).toBe("upcoming");
|
||||
});
|
||||
|
||||
it("returns 'past' for recurring bookings that have ended", () => {
|
||||
expect(
|
||||
getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: past, recurringEventId: "rec-123" }))
|
||||
).toBe("past");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { validStatuses } from "../lib/validStatuses";
|
||||
import type { BookingListingStatus, BookingOutput } from "../types";
|
||||
|
||||
interface BookingForTabResolution {
|
||||
status: string;
|
||||
endTime: Date | string;
|
||||
recurringEventId: string | null;
|
||||
}
|
||||
|
||||
interface UseSwitchToCorrectTabResult {
|
||||
resolvedTabStatus: BookingListingStatus;
|
||||
isResolvingTabStatus: boolean;
|
||||
preSelectedBooking: BookingOutput | null;
|
||||
}
|
||||
|
||||
export function getTabForBooking(booking: BookingForTabResolution): BookingListingStatus {
|
||||
const isPast = new Date(booking.endTime) <= new Date();
|
||||
|
||||
if (booking.status === "CANCELLED" || booking.status === "REJECTED") {
|
||||
return "cancelled";
|
||||
}
|
||||
if (booking.status === "PENDING" && !isPast) {
|
||||
return "unconfirmed";
|
||||
}
|
||||
if (isPast) {
|
||||
return "past";
|
||||
}
|
||||
if (booking.recurringEventId) {
|
||||
return "recurring";
|
||||
}
|
||||
return "upcoming";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the correct status tab is selected based on the pre-selected booking through query params
|
||||
*/
|
||||
export function useSwitchToCorrectStatusTab({
|
||||
defaultStatus,
|
||||
}: {
|
||||
defaultStatus: BookingListingStatus;
|
||||
}): UseSwitchToCorrectTabResult {
|
||||
const {
|
||||
preSelectedBookingUid,
|
||||
preSelectedBooking,
|
||||
preSelectedBookingFull,
|
||||
isPending: isFetchingPreSelectedBooking,
|
||||
} = usePreSelectedBooking();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [resolvedTabStatus, setResolvedTab] = useState<BookingListingStatus>(defaultStatus);
|
||||
const [isNavigatingToCorrectTab, startNavigationToCorrectTab] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!preSelectedBooking) return;
|
||||
const correctTab = getTabForBooking(preSelectedBooking);
|
||||
const currentTab = pathname?.match(/\/bookings\/(\w+)/)?.[1];
|
||||
const shouldNavigate = correctTab && currentTab && correctTab !== currentTab;
|
||||
if (!shouldNavigate) return;
|
||||
startNavigationToCorrectTab(() => {
|
||||
const newPath = pathname.replace(`/bookings/${currentTab}`, `/bookings/${correctTab}`);
|
||||
router.replace(`${newPath}${window.location.search}`);
|
||||
});
|
||||
setResolvedTab(correctTab);
|
||||
}, [preSelectedBooking, pathname, router]);
|
||||
|
||||
const isResolvingTabStatus = preSelectedBookingUid
|
||||
? isFetchingPreSelectedBooking || isNavigatingToCorrectTab
|
||||
: false;
|
||||
|
||||
return { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking: preSelectedBookingFull };
|
||||
}
|
||||
|
||||
export function usePreSelectedBooking(): {
|
||||
preSelectedBookingUid: string | undefined;
|
||||
preSelectedBooking: BookingForTabResolution | null;
|
||||
preSelectedBookingFull: BookingOutput | null;
|
||||
isPending: boolean;
|
||||
}{
|
||||
const searchParams = useSearchParams();
|
||||
const preSelectedBookingUid = searchParams?.get("uid") ?? undefined;
|
||||
|
||||
const { data: preSelectedBookingData, isPending } = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
filters: {
|
||||
bookingUid: preSelectedBookingUid,
|
||||
statuses: [...validStatuses],
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!preSelectedBookingUid,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
const preSelectedBookingFull = preSelectedBookingData?.bookings?.[0] ?? null;
|
||||
const preSelectedBooking: BookingForTabResolution | null = preSelectedBookingFull;
|
||||
return { preSelectedBookingUid, preSelectedBooking, preSelectedBookingFull, isPending };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface IUseVerifyEmailProps {
|
||||
onVerifyEmail?: () => void;
|
||||
name?: string | { firstName: string; lastname?: string };
|
||||
requiresBookerEmailVerification?: boolean;
|
||||
eventTypeId?: number;
|
||||
}
|
||||
export type UseVerifyEmailReturnType = ReturnType<typeof useVerifyEmail>;
|
||||
export const useVerifyEmail = ({
|
||||
@@ -19,6 +20,7 @@ export const useVerifyEmail = ({
|
||||
name,
|
||||
requiresBookerEmailVerification,
|
||||
onVerifyEmail,
|
||||
eventTypeId,
|
||||
}: IUseVerifyEmailProps) => {
|
||||
const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false);
|
||||
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
|
||||
@@ -56,6 +58,7 @@ export const useVerifyEmail = ({
|
||||
email,
|
||||
username: typeof name === "string" ? name : name?.firstName,
|
||||
language: i18n.language || "en",
|
||||
eventTypeId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
isEditableTarget,
|
||||
checkSheetActive,
|
||||
createBookingSheetKeydownHandler,
|
||||
type BookingSheetKeyboardConfig,
|
||||
} from "./bookingSheetKeyboardHandler";
|
||||
|
||||
function createMockKeyboardEvent(key: string, target?: EventTarget | null): KeyboardEvent {
|
||||
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
if (target) {
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("isEditableTarget", () => {
|
||||
it("returns true for input elements", () => {
|
||||
const input = document.createElement("input");
|
||||
expect(isEditableTarget(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for textarea elements", () => {
|
||||
const textarea = document.createElement("textarea");
|
||||
expect(isEditableTarget(textarea)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for contenteditable elements", () => {
|
||||
const div = document.createElement("div");
|
||||
div.contentEditable = "true";
|
||||
expect(isEditableTarget(div)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular div elements", () => {
|
||||
const div = document.createElement("div");
|
||||
expect(isEditableTarget(div)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for button elements", () => {
|
||||
const button = document.createElement("button");
|
||||
expect(isEditableTarget(button)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isEditableTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSheetActive", () => {
|
||||
it("returns true when no element has focus", () => {
|
||||
expect(checkSheetActive(document.createElement("div"), null)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when document.body is focused", () => {
|
||||
expect(checkSheetActive(document.createElement("div"), document.body)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when sheetContent is null", () => {
|
||||
const focusedElement = document.createElement("button");
|
||||
document.body.appendChild(focusedElement);
|
||||
expect(checkSheetActive(null, focusedElement)).toBe(false);
|
||||
document.body.removeChild(focusedElement);
|
||||
});
|
||||
|
||||
it("returns true when focused element is inside sheetContent", () => {
|
||||
const sheetContent = document.createElement("div");
|
||||
const button = document.createElement("button");
|
||||
sheetContent.appendChild(button);
|
||||
document.body.appendChild(sheetContent);
|
||||
expect(checkSheetActive(sheetContent, button)).toBe(true);
|
||||
document.body.removeChild(sheetContent);
|
||||
});
|
||||
|
||||
it("returns true when focused element is outside any Radix portal", () => {
|
||||
const sheetContent = document.createElement("div");
|
||||
const outsideButton = document.createElement("button");
|
||||
document.body.appendChild(sheetContent);
|
||||
document.body.appendChild(outsideButton);
|
||||
expect(checkSheetActive(sheetContent, outsideButton)).toBe(true);
|
||||
document.body.removeChild(sheetContent);
|
||||
document.body.removeChild(outsideButton);
|
||||
});
|
||||
|
||||
it("returns false when focused element is inside a Radix portal (overlay)", () => {
|
||||
const sheetContent = document.createElement("div");
|
||||
const portal = document.createElement("div");
|
||||
portal.setAttribute("data-radix-portal", "");
|
||||
const dropdownButton = document.createElement("button");
|
||||
portal.appendChild(dropdownButton);
|
||||
document.body.appendChild(sheetContent);
|
||||
document.body.appendChild(portal);
|
||||
expect(checkSheetActive(sheetContent, dropdownButton)).toBe(false);
|
||||
document.body.removeChild(sheetContent);
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBookingSheetKeydownHandler", () => {
|
||||
let handlePrevious: () => void;
|
||||
let handleNext: () => void;
|
||||
let mockJoinLink: HTMLAnchorElement;
|
||||
let config: BookingSheetKeyboardConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
handlePrevious = vi.fn();
|
||||
handleNext = vi.fn();
|
||||
mockJoinLink = document.createElement("a");
|
||||
mockJoinLink.click = vi.fn();
|
||||
|
||||
config = {
|
||||
isSheetActive: () => true,
|
||||
canGoPrev: true,
|
||||
canGoNext: true,
|
||||
isTransitioning: false,
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
getJoinLink: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ArrowDown", () => {
|
||||
it("navigates to next when canGoNext is true", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowDown");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handleNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when canGoNext is false", () => {
|
||||
config.canGoNext = false;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowDown");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when transitioning", () => {
|
||||
config.isTransitioning = true;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowDown");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArrowUp", () => {
|
||||
it("navigates to previous when canGoPrev is true", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowUp");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handlePrevious).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when canGoPrev is false", () => {
|
||||
config.canGoPrev = false;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowUp");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handlePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when transitioning", () => {
|
||||
config.isTransitioning = true;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowUp");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handlePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter", () => {
|
||||
it("clicks join link when available", () => {
|
||||
config.getJoinLink = () => mockJoinLink;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("Enter");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(mockJoinLink.click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no join link is available", () => {
|
||||
config.getJoinLink = () => null;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("Enter");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sheet inactive", () => {
|
||||
it("does not handle any keys when sheet is inactive", () => {
|
||||
config.isSheetActive = () => false;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
|
||||
const downEvent = createMockKeyboardEvent("ArrowDown");
|
||||
const downSpy = vi.spyOn(downEvent, "stopPropagation");
|
||||
handler(downEvent);
|
||||
expect(downSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
|
||||
const upEvent = createMockKeyboardEvent("ArrowUp");
|
||||
const upSpy = vi.spyOn(upEvent, "stopPropagation");
|
||||
handler(upEvent);
|
||||
expect(upSpy).not.toHaveBeenCalled();
|
||||
expect(handlePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editable targets", () => {
|
||||
it("ignores events from input elements", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const input = document.createElement("input");
|
||||
const event = createMockKeyboardEvent("ArrowDown", input);
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores events from textarea elements", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const textarea = document.createElement("textarea");
|
||||
const event = createMockKeyboardEvent("ArrowDown", textarea);
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores events from contenteditable elements", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const div = document.createElement("div");
|
||||
div.contentEditable = "true";
|
||||
const event = createMockKeyboardEvent("ArrowDown", div);
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unhandled keys", () => {
|
||||
it("does not preventDefault or stopPropagation for unhandled keys", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("Tab");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface BookingSheetKeyboardConfig {
|
||||
isSheetActive: () => boolean;
|
||||
canGoPrev: boolean | undefined;
|
||||
canGoNext: boolean | undefined;
|
||||
isTransitioning: boolean;
|
||||
handlePrevious: () => void;
|
||||
handleNext: () => void;
|
||||
getJoinLink: () => HTMLAnchorElement | null | undefined;
|
||||
}
|
||||
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
if (target instanceof HTMLElement) {
|
||||
return target.contentEditable === "true";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkSheetActive(
|
||||
sheetContent: HTMLElement | null,
|
||||
activeElement: Element | null
|
||||
): boolean {
|
||||
if (!activeElement || activeElement === document.body) {
|
||||
return true;
|
||||
}
|
||||
if (!sheetContent) return false;
|
||||
if (sheetContent.contains(activeElement)) {
|
||||
return true;
|
||||
}
|
||||
return !activeElement.closest("[data-radix-portal]");
|
||||
}
|
||||
|
||||
export function createBookingSheetKeydownHandler(config: BookingSheetKeyboardConfig) {
|
||||
return (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.isSheetActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (config.canGoPrev && !config.isTransitioning) {
|
||||
config.handlePrevious();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (config.canGoNext && !config.isTransitioning) {
|
||||
config.handleNext();
|
||||
}
|
||||
break;
|
||||
case "Enter": {
|
||||
const joinLink = config.getJoinLink();
|
||||
if (joinLink) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
joinLink.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -938,6 +938,7 @@ export default function Success(props: PageProps) {
|
||||
payment: props.paymentStatus,
|
||||
}}
|
||||
eventTypeMetadata={eventType.metadata}
|
||||
requiresCancellationReason={eventType.requiresCancellationReason}
|
||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
team={eventType?.team?.name}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import LicenseRequired from "~/ee/common/components/LicenseRequired";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { FolderIcon, SparklesIcon, TerminalIcon } from "@coss/ui/icons";
|
||||
|
||||
import { UpgradeTip } from "~/shell/UpgradeTip";
|
||||
|
||||
export const AdminAPIView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <TerminalIcon className="h-5 w-5 text-pink-500" />,
|
||||
title: t("admin_api"),
|
||||
description: t("leverage_our_api"),
|
||||
},
|
||||
{
|
||||
icon: <FolderIcon className="h-5 w-5 text-red-500" />,
|
||||
title: `SCIM & ${t("directory_sync")}`,
|
||||
description: t("directory_sync_description"),
|
||||
},
|
||||
{
|
||||
icon: <SparklesIcon className="h-5 w-5 text-blue-500" />,
|
||||
title: "Cal.ai",
|
||||
description: t("use_cal_ai_to_make_call_description"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div className="mt-8">
|
||||
<UpgradeTip
|
||||
plan="enterprise"
|
||||
title={t("enterprise_license")}
|
||||
description={t("create_your_enterprise_description")}
|
||||
features={features}
|
||||
background="/tips/enterprise"
|
||||
buttons={
|
||||
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href="https://cal.com/sales" target="_blank">
|
||||
{t("contact_sales")}
|
||||
</Button>
|
||||
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
}>
|
||||
<>Create Org</>
|
||||
</UpgradeTip>
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAPIView;
|
||||
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
|
||||
|
||||
import { MemberInvitationModalWithoutMembers } from "~/ee/teams/components/MemberInvitationModal";
|
||||
import InviteLinkSettingsModal from "~/ee/teams/components/InviteLinkSettingsModal";
|
||||
|
||||
import MemberList from "../components/MemberList";
|
||||
|
||||
@@ -37,7 +38,7 @@ interface TeamMembersViewProps {
|
||||
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [_showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
|
||||
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
|
||||
|
||||
// Use PBAC permissions - server-side permission check should be done in parent component
|
||||
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? false;
|
||||
@@ -70,6 +71,18 @@ export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMe
|
||||
onSettingsOpen={() => setShowInviteLinkSettingsModal(true)}
|
||||
/>
|
||||
)}
|
||||
{team?.inviteToken && (
|
||||
<InviteLinkSettingsModal
|
||||
isOpen={showInviteLinkSettingsModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken.token}
|
||||
expiresInDays={team.inviteToken.expiresInDays ?? undefined}
|
||||
onExit={() => {
|
||||
setShowInviteLinkSettingsModal(false);
|
||||
setShowMemberInvitationModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
|
||||
@@ -178,6 +178,8 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
|
||||
agentId: null,
|
||||
inboundAgentId: null,
|
||||
autoTranslateEnabled: false,
|
||||
sourceLocale: null,
|
||||
};
|
||||
steps?.push(step);
|
||||
form.setValue("steps", steps);
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Controller, useWatch } from "react-hook-form";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import type { RetellAgentWithDetails } from "@calcom/features/calAIPhone/providers/retellAI";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import PhoneInput from "@calcom/web/components/phone-input";
|
||||
import {
|
||||
isSMSAction,
|
||||
isWhatsappAction,
|
||||
getTemplateBodyForAction,
|
||||
shouldScheduleEmailReminder,
|
||||
isSMSOrWhatsappAction,
|
||||
hasCalAIAction,
|
||||
isCalAIAction,
|
||||
isFormTrigger,
|
||||
hasCalAIAction,
|
||||
isSMSAction,
|
||||
isSMSOrWhatsappAction,
|
||||
isWhatsappAction,
|
||||
shouldScheduleEmailReminder,
|
||||
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "@calcom/features/ee/workflows/lib/constants";
|
||||
import {
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
import emailRatingTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailRatingTemplate";
|
||||
import emailReminderTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailReminderTemplate";
|
||||
import type { FormValues } from "@calcom/features/ee/workflows/lib/types";
|
||||
import PhoneInput from "@calcom/web/components/phone-input";
|
||||
import "@calcom/features/ee/workflows/style/styles.css";
|
||||
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber";
|
||||
@@ -48,7 +48,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Badge, InfoBadge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -56,26 +56,26 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { AddVariablesDropdown } from "@calcom/ui/components/editor";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { CheckboxField } from "@calcom/ui/components/form";
|
||||
import { EmailField } from "@calcom/ui/components/form";
|
||||
import { TextArea } from "@calcom/ui/components/form";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { MultiSelectCheckbox } from "@calcom/ui/components/form";
|
||||
import { AddVariablesDropdown, Editor } from "@calcom/ui/components/editor";
|
||||
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
|
||||
import {
|
||||
CheckboxField,
|
||||
EmailField,
|
||||
Input,
|
||||
Label,
|
||||
MultiSelectCheckbox,
|
||||
Select,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@calcom/ui/components/form";
|
||||
import { CircleHelpIcon, InfoIcon, PhoneIcon } from "@coss/ui/icons";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useHasPaidPlan, useHasActiveTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
|
||||
|
||||
import { useHasActiveTeamPlan, useHasPaidPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
|
||||
import { AgentConfigurationSheet }from "./agent-configuration/AgentConfigurationSheet";
|
||||
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
|
||||
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
|
||||
import { WebCallDialog } from "./WebCallDialog";
|
||||
import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet";
|
||||
|
||||
type User = RouterOutputs["viewer"]["me"]["get"];
|
||||
|
||||
@@ -477,7 +477,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const verifyEmailCodeMutation = trpc.viewer.workflows.verifyEmailCode.useMutation({
|
||||
onSuccess: (isVerified) => {
|
||||
showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success");
|
||||
setEmailVerified(true);
|
||||
setEmailVerified(isVerified);
|
||||
if (
|
||||
step &&
|
||||
form?.formState?.errors?.steps &&
|
||||
@@ -800,6 +800,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.agentId`, null);
|
||||
}
|
||||
|
||||
if (
|
||||
val.value !== WorkflowActions.EMAIL_ATTENDEE &&
|
||||
val.value !== WorkflowActions.SMS_ATTENDEE
|
||||
) {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.autoTranslateEnabled`, false, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
|
||||
form.setValue(`steps.${step.stepNumber - 1}.sendTo`, null);
|
||||
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
|
||||
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value, {
|
||||
@@ -1433,6 +1442,49 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(step.action === WorkflowActions.EMAIL_ATTENDEE ||
|
||||
step.action === WorkflowActions.SMS_ATTENDEE) && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name={`steps.${step.stepNumber - 1}.autoTranslateEnabled`}
|
||||
control={form.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
disabled={props.readOnly || !props.user.organizationId}
|
||||
defaultChecked={
|
||||
form.getValues(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) || false
|
||||
}
|
||||
description={t("auto_translate_for_attendees")}
|
||||
descriptionClassName="ml-0"
|
||||
onChange={(e) =>
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.autoTranslateEnabled`,
|
||||
e.target.checked,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!props.user.organizationId && (
|
||||
<Badge variant="gray" size="sm">
|
||||
{t("upgrade_to_organizations")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{props.user.organizationId &&
|
||||
form.watch(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) && (
|
||||
<p className="text-subtle ml-6 mt-1 text-xs">
|
||||
{t("auto_translate_source_language_hint", {
|
||||
language: new Intl.DisplayNames([i18n.language], { type: "language" }).of(
|
||||
props.user.locale || "en"
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!props.readOnly && (
|
||||
<div className="ml-1 mt-2">
|
||||
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { md } from "@calcom/lib/markdownIt";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
@@ -32,6 +34,7 @@ const querySchema = z.object({
|
||||
pageSlug: z.string(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
parentId: z.coerce.number().optional().nullable(),
|
||||
schedulingType: z.nativeEnum(SchedulingType).optional().nullable(),
|
||||
});
|
||||
|
||||
const DuplicateDialog = () => {
|
||||
@@ -42,8 +45,9 @@ const DuplicateDialog = () => {
|
||||
const router = useRouter();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const {
|
||||
data: { pageSlug, slug, ...defaultValues },
|
||||
data: { pageSlug, slug, schedulingType, ...defaultValues },
|
||||
} = useTypedQuery(querySchema);
|
||||
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
|
||||
@@ -72,7 +76,11 @@ const DuplicateDialog = () => {
|
||||
|
||||
const duplicateMutation = trpc.viewer.eventTypesHeavy.duplicate.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace(`/event-types/${eventType.id}`);
|
||||
// For managed event types, redirect to the assignment tab so the user can add members
|
||||
const redirectUrl = isManagedEventType
|
||||
? `/event-types/${eventType.id}?tabName=team`
|
||||
: `/event-types/${eventType.id}`;
|
||||
await router.replace(redirectUrl);
|
||||
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
revalidateEventTypesList();
|
||||
@@ -117,7 +125,7 @@ const DuplicateDialog = () => {
|
||||
return (
|
||||
<Dialog
|
||||
name="duplicate"
|
||||
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug"]}>
|
||||
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug", "schedulingType"]}>
|
||||
<DialogContent type="creation" className="overflow-y-auto" title={t("duplicate_event_type")}>
|
||||
<Form
|
||||
form={form}
|
||||
@@ -125,6 +133,12 @@ const DuplicateDialog = () => {
|
||||
duplicateMutation.mutate(values);
|
||||
}}>
|
||||
<div className="-mt-2 stack-y-5">
|
||||
{isManagedEventType && (
|
||||
<Alert
|
||||
severity="info"
|
||||
message={t("managed_event_type_duplicate_disclaimer")}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label={t("title")}
|
||||
placeholder={t("quick_chat")}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { useState, Suspense, useMemo, useEffect } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
||||
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import {
|
||||
SelectedCalendarsSettingsWebWrapper,
|
||||
SelectedCalendarSettingsScope,
|
||||
SelectedCalendarsSettingsWebWrapperSkeleton,
|
||||
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
|
||||
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
|
||||
@@ -20,24 +10,20 @@ import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components";
|
||||
import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail";
|
||||
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
|
||||
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import type {
|
||||
FormValues,
|
||||
EventTypeSetupProps,
|
||||
SelectClassNames,
|
||||
CheckboxClassNames,
|
||||
EventTypeSetupProps,
|
||||
FormValues,
|
||||
InputClassNames,
|
||||
SelectClassNames,
|
||||
SettingsToggleClassNames,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import { FormBuilder } from "./FormBuilder";
|
||||
import { BookerLayoutSelector } from "@calcom/web/modules/settings/components/BookerLayoutSelector";
|
||||
import {
|
||||
DEFAULT_LIGHT_BRAND_COLOR,
|
||||
DEFAULT_DARK_BRAND_COLOR,
|
||||
DEFAULT_LIGHT_BRAND_COLOR,
|
||||
MAX_SEATS_PER_TIME_SLOT,
|
||||
} from "@calcom/lib/constants";
|
||||
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
|
||||
@@ -45,25 +31,36 @@ import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { EditableSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { fieldSchema } from "@calcom/prisma/zod-utils";
|
||||
import { CancellationReasonRequirement, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { EditableSchema, fieldSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
SelectField,
|
||||
ColorPicker,
|
||||
TextField,
|
||||
Label,
|
||||
CheckboxField,
|
||||
Switch,
|
||||
SettingsToggle,
|
||||
ColorPicker,
|
||||
Label,
|
||||
Select,
|
||||
SelectField,
|
||||
SettingsToggle,
|
||||
Switch,
|
||||
TextField,
|
||||
} from "@calcom/ui/components/form";
|
||||
import { InfoIcon, PencilIcon } from "@coss/ui/icons";
|
||||
import {
|
||||
SelectedCalendarSettingsScope,
|
||||
SelectedCalendarsSettingsWebWrapper,
|
||||
SelectedCalendarsSettingsWebWrapperSkeleton,
|
||||
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
|
||||
import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components";
|
||||
import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail";
|
||||
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal";
|
||||
import CustomEventTypeModal from "./CustomEventTypeModal";
|
||||
@@ -71,6 +68,7 @@ import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmails
|
||||
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
|
||||
import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController";
|
||||
import DisableReschedulingController from "./DisableReschedulingController";
|
||||
import { FormBuilder } from "./FormBuilder";
|
||||
import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController";
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
@@ -676,6 +674,43 @@ export const EventAdvancedTab = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="requiresCancellationReason"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const cancellationReasonOptions = [
|
||||
{ value: CancellationReasonRequirement.MANDATORY_BOTH, label: t("mandatory_for_both") },
|
||||
{
|
||||
value: CancellationReasonRequirement.MANDATORY_HOST_ONLY,
|
||||
label: t("mandatory_for_host_only"),
|
||||
},
|
||||
{
|
||||
value: CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY,
|
||||
label: t("mandatory_for_attendee_only"),
|
||||
},
|
||||
{ value: CancellationReasonRequirement.OPTIONAL_BOTH, label: t("optional_for_both") },
|
||||
];
|
||||
return (
|
||||
<div className="border-subtle rounded-lg border px-4 py-6 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-default text-sm font-semibold">{t("require_cancellation_reason")}</p>
|
||||
<p className="text-default text-sm">{t("require_cancellation_reason_description")}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={cancellationReasonOptions.find(
|
||||
(opt) => opt.value === (value || CancellationReasonRequirement.MANDATORY_HOST_ONLY)
|
||||
)}
|
||||
options={cancellationReasonOptions}
|
||||
onChange={(selected) => onChange(selected?.value)}
|
||||
className="w-52"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
|
||||
@@ -432,14 +432,15 @@ export const InfiniteEventTypeList = ({
|
||||
if (value) newSearchParams.set(key, value.toString());
|
||||
if (value === null) newSearchParams.delete(key);
|
||||
}
|
||||
setParamsIfDefined("dialog", "duplicate");
|
||||
setParamsIfDefined("title", eventType.title);
|
||||
setParamsIfDefined("description", eventType.description);
|
||||
setParamsIfDefined("slug", eventType.slug);
|
||||
setParamsIfDefined("id", eventType.id);
|
||||
setParamsIfDefined("length", eventType.length);
|
||||
setParamsIfDefined("pageSlug", group.profile.slug);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
setParamsIfDefined("dialog", "duplicate");
|
||||
setParamsIfDefined("title", eventType.title);
|
||||
setParamsIfDefined("description", eventType.description);
|
||||
setParamsIfDefined("slug", eventType.slug);
|
||||
setParamsIfDefined("id", eventType.id);
|
||||
setParamsIfDefined("length", eventType.length);
|
||||
setParamsIfDefined("pageSlug", group.profile.slug);
|
||||
setParamsIfDefined("schedulingType", eventType.schedulingType);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
@@ -695,18 +696,16 @@ export const InfiniteEventTypeList = ({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
StartIcon="copy"
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
StartIcon="copy"
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
@@ -812,7 +811,7 @@ export const InfiniteEventTypeList = ({
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function FeatureOptInBanner({
|
||||
return (
|
||||
<div
|
||||
data-testid="feature-opt-in-banner"
|
||||
className="bg-default border-subtle fixed bottom-5 right-5 z-50 max-w-xs rounded-lg border shadow-lg group">
|
||||
className="group fixed right-5 bottom-5 z-50 hidden max-w-xs rounded-lg border border-subtle bg-default shadow-lg md:block">
|
||||
<div className="p-4">
|
||||
<h3 data-testid="feature-opt-in-banner-title" className="text-emphasis text-lg font-semibold">
|
||||
{t(featureConfig.i18n.title)}
|
||||
|
||||
@@ -99,7 +99,7 @@ const Download = () => {
|
||||
EndIcon="file-down"
|
||||
color="secondary"
|
||||
loading={isDownloading}
|
||||
className="h-full self-end sm:self-baseline">
|
||||
className="h-full">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
|
||||
import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state";
|
||||
import { weekdayDates } from "@calcom/features/calendars/weeklyview/utils";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
|
||||
@@ -151,6 +151,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
|
||||
footer={
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
data-testid="onboarding-continue-btn"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
|
||||
@@ -117,6 +117,7 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="onboarding-continue-btn"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user