Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d04a23bce | ||
|
|
dd8477c0bf | ||
|
|
aebfd4919b | ||
|
|
69a857ad98 | ||
|
|
af4a10c008 | ||
|
|
90a83a611c | ||
|
|
b33d8c64df | ||
|
|
73b276015c | ||
|
|
9c223fc718 | ||
|
|
6910b85301 | ||
|
|
125ed4f6f7 | ||
|
|
2cab5475ce | ||
|
|
5032d2716d | ||
|
|
3ccf001e1d | ||
|
|
4c282ea9ca | ||
|
|
1135e47338 | ||
|
|
68e670ae06 | ||
|
|
8b13979a41 | ||
|
|
4beebd227b | ||
|
|
853358d9c4 | ||
|
|
7dcf4ee5c8 | ||
|
|
42f2c170aa | ||
|
|
3423777ee3 | ||
|
|
c8a8c37b45 | ||
|
|
f291c1c4e7 | ||
|
|
71792bdeaa | ||
|
|
945ded951f | ||
|
|
975fb21d86 | ||
|
|
b20b52a1f8 | ||
|
|
176aeaafd8 | ||
|
|
9ba867922a | ||
|
|
f1011ddd08 | ||
|
|
9019be58ed | ||
|
|
cef8610925 | ||
|
|
056922b6ee | ||
|
|
42bd4b4057 | ||
|
|
64767cec4f | ||
|
|
e84550ba76 | ||
|
|
e6d0253950 | ||
|
|
471d1caac9 | ||
|
|
15b1675db1 | ||
|
|
c20f6cbdd6 | ||
|
|
03dd0c1501 | ||
|
|
84ecb0e568 | ||
|
|
27775e5d03 | ||
|
|
0986864835 | ||
|
|
cb7844fd22 | ||
|
|
aaaff0705b | ||
|
|
dd27d077ca | ||
|
|
5fe3714edd | ||
|
|
e0a28af46e | ||
|
|
970af8bd6a | ||
|
|
2f1dd92677 | ||
|
|
61c8c9d970 | ||
|
|
0604c960a4 | ||
|
|
c361b9bbc5 | ||
|
|
c2d275551a | ||
|
|
0ea7f1944f | ||
|
|
1af7bfa20e | ||
|
|
641b249e92 | ||
|
|
f3f5725563 | ||
|
|
a2a77376b3 | ||
|
|
ee298af4eb | ||
|
|
47a5fc7bf7 | ||
|
|
a1a5f2477d | ||
|
|
94e0d476d9 | ||
|
|
45fcabef8c | ||
|
|
593ed461c8 | ||
|
|
b8827aca52 | ||
|
|
c3bcd623f6 | ||
|
|
272cf43322 | ||
|
|
a8e2ba4663 | ||
|
|
3082d6c2f3 | ||
|
|
f98acb7301 | ||
|
|
4e3ce77de4 | ||
|
|
0b5b76d594 | ||
|
|
b6935666e5 | ||
|
|
b25a2bf202 | ||
|
|
06d4c7d102 | ||
|
|
5b9dd09f0d | ||
|
|
db2caa17ce | ||
|
|
b83f8dd6b8 | ||
|
|
883ac103f8 | ||
|
|
d13adc8ffd | ||
|
|
d64255be47 | ||
|
|
5c5d221c76 | ||
|
|
852daa0175 | ||
|
|
1123b56a09 | ||
|
|
12e07f20d4 | ||
|
|
18415e35a7 | ||
|
|
856792e1e7 | ||
|
|
52d5261b51 | ||
|
|
53776d28e6 | ||
|
|
2b666f3e91 | ||
|
|
7562ad2a16 | ||
|
|
5626bceeff | ||
|
|
e312d1c044 | ||
|
|
9050f9b61f | ||
|
|
2218a45d83 | ||
|
|
4e1d05a7e7 | ||
|
|
0d4108dd69 | ||
|
|
6e2e826001 | ||
|
|
84a4dd4147 | ||
|
|
deca7ce40a | ||
|
|
5394d80969 | ||
|
|
fd8f2a9ff6 | ||
|
|
28f9c8f4cf | ||
|
|
37884d26e3 | ||
|
|
e46df92492 | ||
|
|
35cf827cb1 | ||
|
|
e9c3d7e40c | ||
|
|
c218a0afad | ||
|
|
b9a09117e3 | ||
|
|
550caa6665 | ||
|
|
a8f43da472 | ||
|
|
43b6c63294 | ||
|
|
9ef650ceed | ||
|
|
e394a03365 | ||
|
|
972f983824 | ||
|
|
68a8fade89 | ||
|
|
d81993892a | ||
|
|
d034e7aff7 | ||
|
|
13103c0881 | ||
|
|
983af06787 | ||
|
|
29076d681b | ||
|
|
3cadf105d5 | ||
|
|
411483a447 | ||
|
|
223c967f81 | ||
|
|
440b69138e | ||
|
|
5a3f15357c | ||
|
|
e46c1bb40c | ||
|
|
758b820a92 | ||
|
|
b46fb82695 | ||
|
|
71c9567169 | ||
|
|
5918e66cc5 | ||
|
|
2ad24048b6 | ||
|
|
a69fe2ed00 | ||
|
|
a5000cb3b7 | ||
|
|
eb0cf10f67 | ||
|
|
728e1173cb | ||
|
|
b4e773da69 | ||
|
|
bf38eb8cb1 | ||
|
|
010ee0d066 | ||
|
|
f0a7d69c7e | ||
|
|
cb2509683d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: Booker accepts rrHostSubsetIds to specify round robin hosts
|
||||
@@ -127,6 +127,14 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
NEXT_PUBLIC_FRESHCHAT_TOKEN=
|
||||
NEXT_PUBLIC_FRESHCHAT_HOST=
|
||||
|
||||
# For holiday feature:
|
||||
# Step-by-step: Get a Google Calendar API Key
|
||||
# 1. Go to Google Cloud Console: https://console.cloud.google.com/
|
||||
# 2. Select or Create a Project
|
||||
# 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API)
|
||||
# 4. Create the API Key (APIs & Services → Credentials)
|
||||
GOOGLE_CALENDAR_API_KEY=
|
||||
|
||||
# Google OAuth credentials
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` below
|
||||
|
||||
@@ -34,6 +34,10 @@ inputs:
|
||||
description: "Whether to push the built image"
|
||||
required: false
|
||||
default: "false"
|
||||
use-as-latest:
|
||||
description: "Publish as latest"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -61,7 +65,7 @@ runs:
|
||||
docker.io/calcom/cal.com
|
||||
ghcr.io/calcom/cal.com
|
||||
flavor: |
|
||||
latest=${{ !github.event.release.prerelease }}
|
||||
latest=${{ !github.event.release.prerelease && inputs.use-as-latest == 'true' }}
|
||||
suffix=${{ inputs.platform-suffix }}
|
||||
|
||||
- name: Copy env
|
||||
|
||||
@@ -37,5 +37,5 @@ jobs:
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
rm -rf packages/platform/atoms/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/atoms run build
|
||||
yarn workspace @calcom/atoms run build-npm
|
||||
shell: bash
|
||||
|
||||
@@ -3,6 +3,8 @@ on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
@@ -11,12 +11,20 @@ jobs:
|
||||
cleanup-report:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
|
||||
with:
|
||||
app-id: ${{ secrets.CI_CAL_APP_ID }}
|
||||
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
|
||||
repositories: 'test-results-2'
|
||||
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Set Git User
|
||||
run: |
|
||||
|
||||
@@ -22,12 +22,20 @@ jobs:
|
||||
# Contains all reports for deleted branch
|
||||
BRANCH_REPORTS_DIR: reports/${{ github.event.ref }}
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
|
||||
with:
|
||||
app-id: ${{ secrets.CI_CAL_APP_ID }}
|
||||
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
|
||||
repositories: 'test-results-2'
|
||||
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
- name: Set Git User
|
||||
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
run: |
|
||||
|
||||
@@ -14,24 +14,29 @@ jobs:
|
||||
name: Build Companion App & Extension
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: companion/package-lock.json
|
||||
bun-version: 1.3.0
|
||||
cache: true
|
||||
cache-dependency-path: companion/bun.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: npm ci
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build Expo web app
|
||||
working-directory: companion
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: 1
|
||||
CI: true
|
||||
run: npx expo export --platform web
|
||||
run: bunx expo export --platform web
|
||||
|
||||
- name: Build browser extension
|
||||
working-directory: companion
|
||||
run: npm run ext:build
|
||||
run: bun run ext:build
|
||||
|
||||
@@ -17,13 +17,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
|
||||
with:
|
||||
app-id: ${{ secrets.CI_CAL_APP_ID }}
|
||||
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
|
||||
repositories: 'cal.com'
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: 'main'
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -16,6 +16,14 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
|
||||
with:
|
||||
app-id: ${{ secrets.CI_CAL_APP_ID }}
|
||||
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
|
||||
repositories: 'cal.com'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -23,7 +31,7 @@ jobs:
|
||||
cache: 'npm'
|
||||
- uses: lingodotdev/lingo.dev@main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
with:
|
||||
api-key: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }}
|
||||
pull-request: true
|
||||
|
||||
@@ -15,18 +15,6 @@ jobs:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
team-labels:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: equitybee/team-label-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -6,16 +6,27 @@ on:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
|
||||
with:
|
||||
app-id: ${{ secrets.CI_CAL_APP_ID }}
|
||||
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
|
||||
repositories: 'cal.com'
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: 'main'
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
|
||||
has_companion: ${{ steps.filter.outputs.has_companion }}
|
||||
commit-sha: ${{ steps.get_sha.outputs.commit-sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -33,7 +34,9 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
has-files-requiring-all-checks:
|
||||
- "!(**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
|
||||
- "!(companion/**|**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
|
||||
has_companion:
|
||||
- "companion/**"
|
||||
- name: Get Latest Commit SHA
|
||||
id: get_sha
|
||||
run: |
|
||||
@@ -148,8 +151,8 @@ jobs:
|
||||
|
||||
build-companion:
|
||||
name: Companion builds
|
||||
needs: [changes, check-label, deps]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
needs: [changes, check-label]
|
||||
if: needs.changes.outputs.has_companion == 'true'
|
||||
uses: ./.github/workflows/companion-build.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -256,11 +259,34 @@ jobs:
|
||||
e2e-api-v2,
|
||||
e2e-embed,
|
||||
e2e-embed-react,
|
||||
e2e-app-store
|
||||
e2e-app-store,
|
||||
]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: fail if conditional jobs failed
|
||||
if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled'))
|
||||
run: exit 1
|
||||
if: |
|
||||
(
|
||||
needs.changes.outputs.has-files-requiring-all-checks == 'true' &&
|
||||
(
|
||||
needs.lint.result != 'success' ||
|
||||
needs.type-check.result != 'success' ||
|
||||
needs.unit-test.result != 'success' ||
|
||||
needs.build.result != 'success' ||
|
||||
needs.build-api-v1.result != 'success' ||
|
||||
needs.build-api-v2.result != 'success' ||
|
||||
needs.build-atoms.result != 'success' ||
|
||||
needs.build-docs.result != 'success' ||
|
||||
needs.integration-test.result != 'success' ||
|
||||
needs.e2e.result != 'success' ||
|
||||
needs.e2e-api-v2.result != 'success' ||
|
||||
needs.e2e-embed.result != 'success' ||
|
||||
needs.e2e-embed-react.result != 'success' ||
|
||||
needs.e2e-app-store.result != 'success'
|
||||
)
|
||||
) ||
|
||||
(
|
||||
needs.changes.outputs.has_companion == 'true' &&
|
||||
needs.build-companion.result != 'success'
|
||||
)
|
||||
|
||||
@@ -13,12 +13,20 @@ jobs:
|
||||
HTML_REPORT_URL_PATH: reports/${{ github.head_ref }}/${{ github.run_id }}/${{ github.run_attempt }}
|
||||
BRANCH_REPORTS_DIR: reports/${{ github.head_ref }}
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
|
||||
with:
|
||||
app-id: ${{ secrets.CI_CAL_APP_ID }}
|
||||
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
|
||||
repositories: 'test-results-2'
|
||||
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
- name: Set Git User
|
||||
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
run: |
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
postgres-db: ${{ env.POSTGRES_DB }}
|
||||
database-host: ${{ env.DATABASE_HOST }}
|
||||
push-image: "true"
|
||||
use-as-latest: "true"
|
||||
|
||||
- name: Notify Slack on Success
|
||||
if: success()
|
||||
|
||||
@@ -45,11 +45,20 @@ yarn prettier --write path/to/file.tsx
|
||||
# Unit test specific file
|
||||
yarn vitest run path/to/file.test.ts
|
||||
|
||||
# Unit test specific file + specific test
|
||||
yarn vitest run path/to/file.test.ts --testNamePattern="specific test name"
|
||||
|
||||
# Integration test specific file
|
||||
yarn test path/to/file.integration-test.ts -- --integrationTestsOnly
|
||||
|
||||
# Integration test specific file + specific test
|
||||
yarn test path/to/file.integration-test.ts --testNamePattern="specific test name" -- --integrationTestsOnly
|
||||
|
||||
# E2E test specific file
|
||||
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts
|
||||
|
||||
# E2E test specific file + specific test
|
||||
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts --grep "specific test name"
|
||||
```
|
||||
|
||||
### Project-wide (use sparingly)
|
||||
|
||||
@@ -77,6 +77,9 @@ RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${N
|
||||
FROM node:20 AS runner
|
||||
|
||||
WORKDIR /calcom
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder-two /calcom ./
|
||||
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||
|
||||
+8
-2
@@ -28,11 +28,17 @@
|
||||
|
||||
- `yarn test` - Run unit tests (vitest)
|
||||
- `yarn test <filename>` - Run tests for specific file
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
|
||||
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific test by name
|
||||
- `yarn test <filename> -t "<testName>"` - Run specific test by name for specific file
|
||||
- `yarn tdd` - Run tests in watch mode
|
||||
- `yarn test:ui` - Run tests with UI interface
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- `yarn test -- --integrationTestsOnly` - Run integration tests (vitest)
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
|
||||
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific integration test by name for specific file
|
||||
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
- `yarn e2e` - Run end-to-end tests (Playwright)
|
||||
|
||||
@@ -11,9 +11,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||
import { PrismaApiKeyRepository } from "@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository";
|
||||
import { ApiKeyService } from "@calcom/features/ee/api-keys/services/ApiKeyService";
|
||||
import type { IDeploymentRepository } from "@calcom/features/ee/deployment/repositories/IDeploymentRepository";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
@@ -21,11 +21,11 @@ import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
|
||||
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
|
||||
vi.mock("@calcom/features/ee/api-keys/services/ApiKeyService", () => ({
|
||||
ApiKeyService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
|
||||
vi.mock("@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository", () => ({
|
||||
PrismaApiKeyRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { PrismaApiKeyRepository } from "@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository";
|
||||
import { ApiKeyService } from "@calcom/features/ee/api-keys/services/ApiKeyService";
|
||||
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
|
||||
import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
|
||||
import { WebhookSchema } from "@calcom/prisma/zod/modelSchema/WebhookSchema";
|
||||
|
||||
const schemaWebhookBaseBodyParams = WebhookSchema.pick({
|
||||
@@ -21,6 +22,7 @@ export const schemaWebhookCreateParams = z
|
||||
eventTypeId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
secret: z.string().optional().nullable(),
|
||||
version: z.nativeEnum(WebhookVersion).optional(),
|
||||
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
|
||||
// appId: z.string().optional().nullable(),
|
||||
})
|
||||
@@ -33,6 +35,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
|
||||
z.object({
|
||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||
secret: z.string().optional().nullable(),
|
||||
version: z.nativeEnum(WebhookVersion).optional(),
|
||||
})
|
||||
)
|
||||
.partial()
|
||||
@@ -44,6 +47,7 @@ export const schemaWebhookReadPublic = WebhookSchema.pick({
|
||||
eventTypeId: true,
|
||||
payloadTemplate: true,
|
||||
eventTriggers: true,
|
||||
version: true,
|
||||
// FIXME: We have some invalid urls saved in the DB
|
||||
// subscriberUrl: true,
|
||||
/** @todo: find out how to properly add back and validate those. */
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "15.4.9",
|
||||
"next": "15.4.10",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -161,7 +161,7 @@ vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflows", () => ({
|
||||
workflowSelect: {},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
|
||||
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () => ({
|
||||
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
|
||||
+7
@@ -27,6 +27,7 @@ import {
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { slugifyLenient } from "@calcom/platform-libraries";
|
||||
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
validateCustomEventName,
|
||||
@@ -129,6 +130,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
slug,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
@@ -144,6 +146,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const slugifiedSlug = slugifyLenient(slug);
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
@@ -153,6 +157,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
slug: slugifiedSlug,
|
||||
length: lengthInMinutes,
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
|
||||
@@ -209,6 +214,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
slug,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
@@ -243,6 +249,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
...(slug ? { slug: slugifyLenient(slug) } : {}),
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: effectiveBookingFields
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaHolidayRepository } from "@/lib/repositories/prisma-holiday.repository";
|
||||
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
imports: [PrismaWorkerModule],
|
||||
providers: [
|
||||
PrismaOOORepository,
|
||||
PrismaHolidayRepository,
|
||||
PrismaScheduleRepository,
|
||||
PrismaBookingRepository,
|
||||
PrismaSelectedSlotRepository,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaHolidayRepository as PrismaHolidayRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaHolidayRepository extends PrismaHolidayRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
|
||||
import { BookingAuditTaskerProducerService, getAuditActorRepository } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
|
||||
constructor(taskerService: TaskerService) {
|
||||
constructor(taskerService: TaskerService, bridgeLogger: Logger) {
|
||||
super({
|
||||
tasker: taskerService.getTasker(),
|
||||
log: bridgeLogger,
|
||||
auditActorRepository: getAuditActorRepository(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaHolidayRepository } from "@/lib/repositories/prisma-holiday.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -12,13 +13,15 @@ export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||
oooRepoDependency: PrismaOOORepository,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
redisService: RedisService
|
||||
redisService: RedisService,
|
||||
holidayRepository: PrismaHolidayRepository
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
bookingRepo: bookingRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
redisClient: redisService,
|
||||
holidayRepo: holidayRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { slugifyLenient } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
@@ -42,11 +43,13 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const slugifiedInputEventType = { ...inputEventType, slug: slugifyLenient(inputEventType.slug) };
|
||||
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
await this.validateTeamEventTypeSlug(teamId, slugifiedInputEventType.slug);
|
||||
|
||||
const transformedBody = await this.transformInputCreateTeamEventType(teamId, inputEventType);
|
||||
const transformedBody = await this.transformInputCreateTeamEventType(teamId, slugifiedInputEventType);
|
||||
|
||||
await this.inputEventTypesService.validateEventTypeInputs({
|
||||
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
|
||||
@@ -75,13 +78,21 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const slugifiedInputEventType = inputEventType.slug
|
||||
? { ...inputEventType, slug: slugifyLenient(inputEventType.slug) }
|
||||
: inputEventType;
|
||||
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
if (inputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
if (slugifiedInputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, slugifiedInputEventType.slug);
|
||||
}
|
||||
|
||||
const transformedBody = await this.transformInputUpdateTeamEventType(eventTypeId, teamId, inputEventType);
|
||||
const transformedBody = await this.transformInputUpdateTeamEventType(
|
||||
eventTypeId,
|
||||
teamId,
|
||||
slugifiedInputEventType
|
||||
);
|
||||
|
||||
await this.inputEventTypesService.validateEventTypeInputs({
|
||||
eventTypeId: eventTypeId,
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ describe("Organizations Roles Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ describe("Organizations Roles Permissions Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ describe("Organizations Roles Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ describe("Organizations Teams Roles Permissions Endpoints", () => {
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
|
||||
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
|
||||
@@ -64,7 +64,7 @@ export class SlotsController_2024_09_04 {
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
@@ -74,7 +74,10 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'.
|
||||
**Note for managed event types**: Managed event types are templates that create individual child event types for each team member. You cannot fetch slots for the parent managed event type directly. Instead, you must:
|
||||
- Find the child event type IDs (the ones assigned to specific users)
|
||||
- Use those child event type IDs to fetch slots as individual user event types using as described in the individual user section above.
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
|
||||
import { WebhookTriggerEvents } from "@calcom/platform-libraries";
|
||||
import { WebhookTriggerEvents, WebhookVersion } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateWebhookInputDto {
|
||||
@IsString()
|
||||
@@ -48,6 +48,15 @@ export class CreateWebhookInputDto {
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
secret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WebhookVersion)
|
||||
@ApiPropertyOptional({
|
||||
description: "The version of the webhook",
|
||||
example: WebhookVersion.V_2021_10_20,
|
||||
enum: WebhookVersion,
|
||||
})
|
||||
version?: WebhookVersion;
|
||||
}
|
||||
|
||||
export class UpdateWebhookInputDto extends PartialType(CreateWebhookInputDto) {}
|
||||
|
||||
@@ -28,21 +28,38 @@ export class FeaturesRepositoryFixture {
|
||||
});
|
||||
}
|
||||
|
||||
async enableFeatureForTeam(teamId: number, featureId: string, assignedBy = "test") {
|
||||
await this.prismaWriteClient.teamFeatures.upsert({
|
||||
where: {
|
||||
teamId_featureId: {
|
||||
async setTeamFeatureState(
|
||||
teamId: number,
|
||||
featureId: string,
|
||||
state: "enabled" | "disabled" | "inherit",
|
||||
assignedBy = "test"
|
||||
) {
|
||||
if (state === "enabled" || state === "disabled") {
|
||||
await this.prismaWriteClient.teamFeatures.upsert({
|
||||
where: {
|
||||
teamId_featureId: {
|
||||
teamId,
|
||||
featureId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
featureId,
|
||||
assignedBy,
|
||||
enabled: state === "enabled",
|
||||
},
|
||||
update: {
|
||||
enabled: state === "enabled",
|
||||
},
|
||||
});
|
||||
} else if (state === "inherit") {
|
||||
await this.prismaWriteClient.teamFeatures.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
featureId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
featureId,
|
||||
assignedBy,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async disableFeatureForTeam(teamId: number, featureSlug: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
type PageProps as ClientPageProps,
|
||||
} from "~/bookings/views/bookings-single-view.getServerSideProps";
|
||||
|
||||
export const metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
type PageProps as ClientPageProps,
|
||||
} from "~/bookings/views/bookings-single-view.getServerSideProps";
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
|
||||
const { bookingInfo, eventType, recurringBookings, orgSlug } = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation;
|
||||
|
||||
return await _generateMetadata(
|
||||
const metadata = await _generateMetadata(
|
||||
(t) =>
|
||||
t(`booking_${needsConfirmation ? "submitted" : "confirmed"}${recurringBookings ? "_recurring" : ""}`),
|
||||
(t) =>
|
||||
@@ -31,9 +33,15 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
|
||||
getOrgFullOrigin(orgSlug),
|
||||
`/booking/${(await params).uid}`
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
return {
|
||||
...metadata,
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: _PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
@@ -32,11 +32,7 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
const ns = "common";
|
||||
let translations;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
translations = await loadTranslations(eventLocale, ns);
|
||||
}
|
||||
const translations = await loadTranslations(eventLocale ?? "en", ns);
|
||||
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return eventLocale ? (
|
||||
|
||||
@@ -68,11 +68,7 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
const ns = "common";
|
||||
let translations;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
translations = await loadTranslations(eventLocale, ns);
|
||||
}
|
||||
const translations = await loadTranslations(eventLocale ?? "en", ns);
|
||||
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return eventLocale ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
@@ -34,6 +34,7 @@ const Page = async ({ params }: PageProps) => {
|
||||
if (!parsed.success) {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
let canReadOthersBookings = false;
|
||||
@@ -53,15 +54,16 @@ const Page = async ({ params }: PageProps) => {
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
const userProfile = session?.user?.profile;
|
||||
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const bookingsV3Enabled = orgId
|
||||
? await featuresRepository.checkIfTeamHasFeature(orgId, "bookings-v3")
|
||||
const bookingsV3Enabled = session?.user?.id
|
||||
? await featuresRepository.checkIfUserHasFeature(session.user.id, "bookings-v3")
|
||||
: false;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir>
|
||||
<ShellMainAppDir
|
||||
heading={t("bookings")}
|
||||
subtitle={t("bookings_description")}
|
||||
headerClassName="bookings-shell-heading">
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
|
||||
@@ -19,6 +19,7 @@ import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { markdownToSafeHTMLClient } from "@calcom/lib/markdownToSafeHTMLClient";
|
||||
import { navigateInTopWindow } from "@calcom/lib/navigateInTopWindow";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -208,7 +209,10 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="main sm:border-subtle bg-default -mx-4 rounded-md border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
|
||||
<div className="text-emphasis">{customPageMessage}</div>
|
||||
<div
|
||||
className="text-emphasis prose prose-sm dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToSafeHTMLClient(customPageMessage) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ColumnFilterType, DateRangeFilter, DataTableProvider } from "@calcom/features/data-table";
|
||||
import type { DateRangeFilterOptions } from "@calcom/features/data-table/lib/types";
|
||||
|
||||
type DemoRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<DemoRow>();
|
||||
|
||||
type ScenarioProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
expected: string;
|
||||
range: DateRangeFilterOptions["range"];
|
||||
};
|
||||
|
||||
const scenarios: ScenarioProps[] = [
|
||||
{
|
||||
id: "past",
|
||||
title: 'Range: "past"',
|
||||
description: "Restricts date selection to past dates only. Shows presets that are past-compatible.",
|
||||
expected:
|
||||
"Presets visible: Today, Last 7 days, Last 30 days, Month to date, Year to date, Custom. Calendar maxDate = today.",
|
||||
range: "past",
|
||||
},
|
||||
{
|
||||
id: "future",
|
||||
title: 'Range: "future"',
|
||||
description: "Restricts date selection to future dates only. Shows only future-compatible presets.",
|
||||
expected: "Presets visible: Custom only (presets with direction 'any'). Calendar minDate = today.",
|
||||
range: "future",
|
||||
},
|
||||
{
|
||||
id: "any",
|
||||
title: 'Range: "any"',
|
||||
description: "No date restrictions. Shows all presets.",
|
||||
expected: "All presets visible. No calendar date restrictions.",
|
||||
range: "any",
|
||||
},
|
||||
{
|
||||
id: "customOnly",
|
||||
title: 'Range: "customOnly"',
|
||||
description: "Forces custom date picker only. Always hides presets dropdown.",
|
||||
expected: "No presets dropdown. Only calendar picker visible when opened. No date restrictions.",
|
||||
range: "customOnly",
|
||||
},
|
||||
];
|
||||
|
||||
function ScenarioCard({ scenario }: { scenario: ScenarioProps }) {
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("date", {
|
||||
id: "dateRange",
|
||||
header: "Date Range",
|
||||
cell: (info) => info.getValue(),
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: scenario.range,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[scenario.range]
|
||||
);
|
||||
|
||||
const data = useMemo<DemoRow[]>(
|
||||
() => [
|
||||
{ id: 1, name: "Demo Item 1", date: "2024-01-15" },
|
||||
{ id: 2, name: "Demo Item 2", date: "2024-02-20" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
// Get the column definition to pass to DateRangeFilter
|
||||
const dateRangeColumn = table.getAllColumns().find((col) => col.id === "dateRange");
|
||||
const columnMeta = dateRangeColumn?.columnDef.meta as
|
||||
| { filter?: { type: string; dateRangeOptions?: DateRangeFilterOptions } }
|
||||
| undefined;
|
||||
const dateRangeOptions = columnMeta?.filter?.dateRangeOptions;
|
||||
|
||||
return (
|
||||
<div className="border-subtle mb-8 rounded-lg border p-6" data-testid={`drf-scenario-${scenario.id}`}>
|
||||
<h3 className="text-emphasis mb-2 text-lg font-semibold">{scenario.title}</h3>
|
||||
<p className="text-default mb-2 text-sm">{scenario.description}</p>
|
||||
<p className="text-subtle mb-4 text-xs">
|
||||
<strong>Expected:</strong> {scenario.expected}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<DataTableProvider tableIdentifier={`playground-date-range-${scenario.id}`}>
|
||||
{dateRangeColumn && (
|
||||
<DateRangeFilter
|
||||
column={{
|
||||
id: dateRangeColumn.id,
|
||||
title: dateRangeColumn.columnDef.header as string,
|
||||
type: "dr",
|
||||
}}
|
||||
options={dateRangeOptions}
|
||||
showColumnName={false}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
</DataTableProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DateRangeFilterPlayground() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-emphasis text-2xl font-bold">DateRangeFilter Playground</h1>
|
||||
<p className="text-default mt-2">
|
||||
This page demonstrates the different <code>range</code> options for the DateRangeFilter component.
|
||||
</p>
|
||||
<p className="text-subtle mt-1 text-sm">
|
||||
The <code>range</code> option controls both date restrictions and presets visibility. Presets
|
||||
visibility is derived automatically based on compatible presets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scenarios.map((scenario) => (
|
||||
<ScenarioCard key={scenario.id} scenario={scenario} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,32 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import Link from "next/link";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
title: "Routing Funnel",
|
||||
description: "Visualize booking conversion flow and routing patterns",
|
||||
href: "/settings/admin/playground/routing-funnel",
|
||||
icon: "filter" as const,
|
||||
},
|
||||
{
|
||||
title: "Bookings by Hour",
|
||||
description: "View booking distribution across different hours",
|
||||
href: "/settings/admin/playground/bookings-by-hour",
|
||||
icon: "chart-bar" as const,
|
||||
},
|
||||
{
|
||||
title: "Weekly Calendar",
|
||||
description: "Interactive weekly calendar view for scheduling",
|
||||
href: "/settings/admin/playground/weekly-calendar",
|
||||
icon: "calendar" as const,
|
||||
},
|
||||
{
|
||||
title: "Date Range Filter",
|
||||
description: "Test date range selection and filtering components",
|
||||
href: "/settings/admin/playground/date-range-filter",
|
||||
icon: "calendar-days" as const,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,16 +44,27 @@ const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader title={t("playground")} description={t("admin_playground_description")}>
|
||||
<div>
|
||||
<ul className="mt-8">
|
||||
<div className="mt-6">
|
||||
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-md border">
|
||||
{LINKS.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link href={link.href} className="list-item list-disc font-medium underline">
|
||||
{link.title} →
|
||||
</Link>
|
||||
</li>
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="hover:bg-muted group flex items-center gap-4 p-5 transition-colors">
|
||||
<div className="bg-emphasis/10 group-hover:bg-emphasis/20 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md transition-colors">
|
||||
<Icon name={link.icon} className="text-emphasis h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-sm font-semibold leading-none">{link.title}</h3>
|
||||
<p className="text-subtle mt-2 text-sm">{link.description}</p>
|
||||
</div>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
className="text-subtle group-hover:text-emphasis h-4 w-4 flex-shrink-0 transition-colors"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequir
|
||||
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const userIdSchema = z.object({ id: z.coerce.number() });
|
||||
|
||||
@@ -41,6 +41,7 @@ const Page = async ({ params }: { params: Params }) => {
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.adminFindById(input.data.id);
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PrismaApiKeyRepository } from "@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
|
||||
+2
-11
@@ -1,7 +1,6 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
|
||||
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
|
||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
@@ -16,21 +15,13 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
);
|
||||
|
||||
const Page = async ({ params: _params }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const params = await _params;
|
||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||
|
||||
const webhookRepository = WebhookRepository.getInstance();
|
||||
const webhook = await webhookRepository.findByWebhookId(id);
|
||||
|
||||
return (
|
||||
<SettingsHeaderWithBackButton
|
||||
title={t("edit_webhook")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}>
|
||||
<EditWebhookView webhook={webhook} />
|
||||
</SettingsHeaderWithBackButton>
|
||||
);
|
||||
return <EditWebhookView webhook={webhook} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -20,7 +20,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
||||
const getTeamFeatures = unstable_cache(
|
||||
async (teamId: number) => {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
return await featuresRepository.getTeamFeatures(teamId);
|
||||
return await featuresRepository.getEnabledTeamFeatures(teamId);
|
||||
},
|
||||
["team-features"],
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ import prisma from "@calcom/prisma";
|
||||
import type { OAuthTokenPayload } from "@calcom/types/oauth";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
|
||||
const { client_id, client_secret, grant_type, refresh_token } = await parseUrlFormData(req);
|
||||
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
|
||||
@@ -62,11 +62,6 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
|
||||
if (pkceError) {
|
||||
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
|
||||
}
|
||||
|
||||
const accessTokenExpiresIn = 1800; // 30 minutes
|
||||
|
||||
const payloadAuthToken: OAuthTokenPayload = {
|
||||
@@ -83,11 +78,6 @@ async function handler(req: NextRequest) {
|
||||
scope: decodedRefreshToken.scope,
|
||||
token_type: "Refresh Token",
|
||||
clientId: client_id,
|
||||
// Preserve PKCE information for any client that used PKCE originally
|
||||
...(decodedRefreshToken.codeChallenge && {
|
||||
codeChallenge: decodedRefreshToken.codeChallenge,
|
||||
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
|
||||
}),
|
||||
};
|
||||
|
||||
const access_token = jwt.sign(payloadAuthToken, secretKey, {
|
||||
|
||||
@@ -7,6 +7,9 @@ import { z } from "zod";
|
||||
|
||||
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||
import { hashPassword } from "@calcom/lib/auth/hashPassword";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -36,8 +39,14 @@ async function handler(req: NextRequest) {
|
||||
// token verified, delete the cookie / a resubmit on failure requires a new csrf token.
|
||||
cookieStore.delete("calcom.csrf_token");
|
||||
|
||||
// rate-limited there is a low, very low chance that a password request stays valid long enough
|
||||
// to brute force 3.8126967e+40 options.
|
||||
const remoteIp = getIP(req);
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:reset-password:${piiHasher.hash(remoteIp)}`,
|
||||
});
|
||||
|
||||
// Note: There is a low, very low chance that a password request stays valid long enough
|
||||
// to brute force 3.8126967e+40 options, but rate limiting provides additional protection.
|
||||
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
where: {
|
||||
id: rawRequestId,
|
||||
|
||||
@@ -5,10 +5,12 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -38,6 +40,12 @@ async function handler(req: NextRequest) {
|
||||
const remoteIp = getIP(req);
|
||||
// Use a try catch instead of returning res every time
|
||||
try {
|
||||
// Rate limit: 10 signups per 60 seconds per IP
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:signup:${piiHasher.hash(remoteIp)}`,
|
||||
});
|
||||
|
||||
const body = await parseRequestData(req);
|
||||
await checkCfTurnstileToken({
|
||||
token: req.headers.get("cf-access-token") as string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -27,6 +28,11 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:totp-disable:${session.user.id}`,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -25,6 +26,11 @@ async function postHandler(req: NextRequest) {
|
||||
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:totp-enable:${session.user.id}`,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!user) {
|
||||
console.error(`Session references user that no longer exists.`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import qrcode from "qrcode";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
@@ -29,6 +30,11 @@ async function postHandler(req: NextRequest) {
|
||||
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `api:totp-setup:${session.user.id}`,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
|
||||
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
|
||||
|
||||
@@ -26,6 +29,15 @@ async function handler(req: NextRequest) {
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
// Rate limit: 10 booking cancellations per 60 seconds per user (or IP if not authenticated)
|
||||
const identifier = session?.user?.id
|
||||
? `api:cancel-user:${session.user.id}`
|
||||
: `api:cancel-ip:${piiHasher.hash(getIP(req))}`;
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier,
|
||||
});
|
||||
|
||||
const result = await handleCancelBooking({
|
||||
bookingData,
|
||||
userId: session?.user?.id || -1,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscrip
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir:
|
||||
(handler: (req: NextRequest) => Promise<Response>) =>
|
||||
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
|
||||
handler(req),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn().mockResolvedValue(new Headers()),
|
||||
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
|
||||
const location = typeof url === "string" ? url : url.toString();
|
||||
return {
|
||||
status: init?.status ?? 302,
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
|
||||
},
|
||||
} as unknown as Response;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn().mockReturnValue(
|
||||
JSON.stringify({
|
||||
bookingUid: "test-booking-uid",
|
||||
userId: 1,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
booking: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
uid: "test-booking-uid",
|
||||
recurringEventId: null,
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
locale: "en",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/createContext", () => ({
|
||||
createContext: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
|
||||
bookingsRouter: {},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/trpc", () => ({
|
||||
createCallerFactory: vi.fn().mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockResolvedValue({}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { GET } from "../route";
|
||||
|
||||
const createMockRequest = (url: string): NextRequest => {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
method: "GET",
|
||||
url,
|
||||
nextUrl: {
|
||||
searchParams: urlObj.searchParams,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
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 () => {
|
||||
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
});
|
||||
|
||||
it("should preserve custom domain origin in redirect URL", async () => {
|
||||
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should preserve self-hosted domain origin in redirect URL", async () => {
|
||||
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should construct redirect URLs relative to the request URL for various origins", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
"https://calcom.company.internal",
|
||||
"http://192.168.1.100:3000",
|
||||
];
|
||||
|
||||
for (const origin of testOrigins) {
|
||||
vi.clearAllMocks();
|
||||
const baseUrl = `${origin}/api/link?action=accept&token=encrypted-token`;
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET handler - error handling", () => {
|
||||
it("should redirect with error message when TRPC throws an error", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock createCallerFactory to throw a TRPCError
|
||||
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
|
||||
vi.mocked(createCallerFactory).mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })),
|
||||
}));
|
||||
|
||||
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
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 () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock createCallerFactory to throw a TRPCError
|
||||
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
|
||||
vi.mocked(createCallerFactory).mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })),
|
||||
}));
|
||||
|
||||
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,6 @@ const createSessionGetter = (userId: number) => async () => {
|
||||
};
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries()));
|
||||
@@ -120,10 +119,10 @@ async function handler(request: NextRequest) {
|
||||
} catch (e) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`);
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir:
|
||||
(handler: (req: NextRequest) => Promise<Response>) =>
|
||||
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
|
||||
handler(req),
|
||||
}));
|
||||
|
||||
vi.mock("app/api/parseRequestData", () => ({
|
||||
parseRequestData: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn().mockResolvedValue(new Headers()),
|
||||
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
|
||||
const location = typeof url === "string" ? url : url.toString();
|
||||
return {
|
||||
status: init?.status ?? 302,
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
|
||||
},
|
||||
} as unknown as Response;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
booking: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/createContext", () => ({
|
||||
createContext: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
|
||||
bookingsRouter: {},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/trpc", () => ({
|
||||
createCallerFactory: vi.fn().mockReturnValue(() => ({
|
||||
confirm: vi.fn().mockResolvedValue({}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
method,
|
||||
url,
|
||||
nextUrl: {
|
||||
searchParams: urlObj.searchParams,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
describe("verify-booking-token route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET handler", () => {
|
||||
it("should redirect to booking page with error when action is reject (requires POST)", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method");
|
||||
});
|
||||
|
||||
it("should redirect with error when query params are invalid (missing action)", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should redirect with error when query params are invalid (missing token)", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?action=accept&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
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 () => {
|
||||
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");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should handle empty bookingUid in error case", async () => {
|
||||
const baseUrl = "https://app.example.com/api/verify-booking-token?token=test-token&userId=42";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.pathname).toBe("/booking/");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST handler", () => {
|
||||
it("should redirect with status 303 when query params are invalid", async () => {
|
||||
const baseUrl =
|
||||
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
|
||||
const req = createMockRequest(baseUrl, "POST");
|
||||
|
||||
const res = await POST(req, { params: Promise.resolve({}) });
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
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 () => {
|
||||
const baseUrl =
|
||||
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1";
|
||||
const req = createMockRequest(baseUrl, "POST");
|
||||
|
||||
const res = await POST(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirect URL construction", () => {
|
||||
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
"https://calcom.company.internal",
|
||||
"http://192.168.1.100:3000",
|
||||
];
|
||||
|
||||
for (const origin of testOrigins) {
|
||||
vi.clearAllMocks();
|
||||
const baseUrl = `${origin}/api/verify-booking-token?action=reject&token=t&bookingUid=test-uid&userId=1`;
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
|
||||
const res = await GET(req, { params: Promise.resolve({}) });
|
||||
const location = res.headers.get("location");
|
||||
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-uid");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,6 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
async function getHandler(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
|
||||
|
||||
try {
|
||||
@@ -38,21 +37,20 @@ async function getHandler(request: NextRequest) {
|
||||
if (action === DirectAction.REJECT) {
|
||||
// Rejections should use POST method
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url)
|
||||
);
|
||||
}
|
||||
|
||||
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function postHandler(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
|
||||
|
||||
try {
|
||||
@@ -61,10 +59,10 @@ async function postHandler(request: NextRequest) {
|
||||
const { reason } = z.object({ reason: z.string().optional() }).parse(body || {});
|
||||
|
||||
return await handleBookingAction(action, token, bookingUid, userId, request, reason);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -78,14 +76,13 @@ async function handleBookingAction(
|
||||
request: NextRequest,
|
||||
reason?: string
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { oneTimePassword: token },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -143,7 +140,7 @@ async function handleBookingAction(
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`,
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -153,7 +150,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CalendarCacheEventService } from "@calcom/features/calendar-subscriptio
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
|
||||
@@ -91,13 +91,13 @@ vi.mock("@calcom/emails/email-manager", () => ({
|
||||
const mockFindByPhoneNumber = vi.fn();
|
||||
const mockFindByProviderAgentId = vi.fn();
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
|
||||
vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => ({
|
||||
PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({
|
||||
findByPhoneNumber: mockFindByPhoneNumber,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
|
||||
vi.mock("@calcom/features/calAIPhone/repositories/PrismaAgentRepository", () => ({
|
||||
PrismaAgentRepository: vi.fn().mockImplementation(() => ({
|
||||
findByProviderAgentId: mockFindByProviderAgentId,
|
||||
})),
|
||||
|
||||
@@ -4,12 +4,12 @@ import { NextResponse } from "next/server";
|
||||
import { Retell } from "retell-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PrismaAgentRepository } from "@calcom/features/calAIPhone/repositories/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository";
|
||||
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
|
||||
|
||||
Vendored
+1
-1
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
import { TravelScheduleRepository } from "@calcom/features/travelSchedule/repositories/TravelScheduleRepository";
|
||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||
import { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule";
|
||||
import { unstable_cache } from "@calcom/lib/unstable_cache";
|
||||
|
||||
const CACHE_TAGS = {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const viewport = {
|
||||
|
||||
export const metadata = {
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
icon: "/api/logo?type=favicon-32",
|
||||
apple: "/api/logo?type=apple-touch-icon",
|
||||
other: [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
import {
|
||||
getOrgDomainConfigFromHostname,
|
||||
@@ -40,9 +41,8 @@ function getPageInfo(pathname: string, host: string) {
|
||||
return {
|
||||
username: currentOrgDomain ?? "",
|
||||
pageType: PageType.ORG,
|
||||
url: `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${
|
||||
currentOrgDomain?.replace("/", "") ?? ""
|
||||
}`,
|
||||
url: `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain?.replace("/", "") ?? ""
|
||||
}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,12 @@ export function NotFound({ host }: { host: string }) {
|
||||
const isSubpage = pathname?.includes("/", 2) || isBookingSuccessPage;
|
||||
const isInsights = pathname?.startsWith("/insights");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.CalComPageStatus = "404";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: t("enterprise"),
|
||||
@@ -156,9 +162,8 @@ export function NotFound({ host }: { host: string }) {
|
||||
<span className="focus:outline-none">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("register")}{" "}
|
||||
<strong className="text-green-500">{`${
|
||||
pageType === PageType.TEAM ? `${new URL(WEBSITE_URL).host}/team/` : ""
|
||||
}${username}${pageType === PageType.ORG ? `.${subdomainSuffix()}` : ""}`}</strong>
|
||||
<strong className="text-green-500">{`${pageType === PageType.TEAM ? `${new URL(WEBSITE_URL).host}/team/` : ""
|
||||
}${username}${pageType === PageType.ORG ? `.${subdomainSuffix()}` : ""}`}</strong>
|
||||
</span>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/coss-ui/src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@coss/ui/hooks",
|
||||
"utils": "@coss/ui/lib/utils",
|
||||
"ui": "@coss/ui/components"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
|
||||
@@ -16,16 +13,6 @@ export type PageWrapperProps = Readonly<{
|
||||
}>;
|
||||
|
||||
function PageWrapper(props: PageWrapperProps) {
|
||||
const pathname = usePathname();
|
||||
let pageStatus = "200";
|
||||
|
||||
if (pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
} else if (pathname === "/403") {
|
||||
pageStatus = "403";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
@@ -41,13 +28,6 @@ function PageWrapper(props: PageWrapperProps) {
|
||||
<>
|
||||
<AppProviders {...providerProps}>
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
||||
// And we don't want it to error here anyways
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
{props.requiresLicense ? (
|
||||
<LicenseRequired>{props.children}</LicenseRequired>
|
||||
) : (
|
||||
|
||||
@@ -39,7 +39,7 @@ export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) {
|
||||
const mutation = trpc.viewer.credentials.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("app_removed_successfully"), "success");
|
||||
onSuccess && onSuccess();
|
||||
onSuccess?.();
|
||||
setConfirmationDialogOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -52,12 +52,12 @@ export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const getUserDisplayName = (user: (typeof credentials)[number]["user"]) => {
|
||||
const getUserDisplayName = (user: (typeof credentials)[number]["user"]): string | null => {
|
||||
if (!user) return null;
|
||||
// Check if 'name' property exists on user
|
||||
if ("name" in user) return user.name;
|
||||
// Otherwise use email if available
|
||||
if ("email" in user) return user.email;
|
||||
// Check if 'name' property exists and has a truthy string value
|
||||
if ("name" in user && typeof user.name === "string" && user.name) return user.name;
|
||||
// Otherwise use email if available and it's a string
|
||||
if ("email" in user && typeof user.email === "string" && user.email) return user.email;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
|
||||
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
||||
@@ -41,6 +41,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
|
||||
import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore";
|
||||
import type { BookingAttendee } from "../../modules/bookings/types";
|
||||
import { AcceptBookingButton } from "./AcceptBookingButton";
|
||||
import { RejectBookingButton } from "./RejectBookingButton";
|
||||
@@ -136,6 +137,7 @@ const ConditionalLink = ({
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const parsedBooking = buildParsedBooking(booking);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
const { onClick } = booking;
|
||||
@@ -144,6 +146,21 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
|
||||
// Get selected booking UID from store
|
||||
// The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true)
|
||||
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
||||
const isSelected = !!selectedBookingUid && selectedBookingUid === booking.uid;
|
||||
|
||||
// Scroll into view when this booking becomes selected
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => ({
|
||||
...attendee,
|
||||
noShow: attendee.noShow || false,
|
||||
@@ -265,10 +282,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
data-booking-list-item="true"
|
||||
className="hover:bg-cal-muted group w-full">
|
||||
data-booking-uid={booking.uid}
|
||||
className={classNames(
|
||||
"group relative w-full transition-all duration-100 ease-out",
|
||||
"hover:bg-cal-muted",
|
||||
isSelected &&
|
||||
"bg-cal-muted before:bg-brand-default rounded-r-md before:absolute before:left-0 before:top-0 before:h-full before:w-1"
|
||||
)}>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
|
||||
<div className="flex h-full items-center">
|
||||
@@ -381,7 +405,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
"max-w-10/12 text-emphasis sm:max-w-56 break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
@@ -395,7 +419,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
className="max-w-10/12 text-default sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isWithinMinimumRescheduleNotice } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
|
||||
@@ -122,8 +123,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
? `?seatReferenceUid=${seatReferenceUid}`
|
||||
: ""
|
||||
}`,
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||
disabled: isActionDisabled("reschedule", {
|
||||
...context,
|
||||
booking,
|
||||
isBookingInPast,
|
||||
isDisabledRescheduling,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
@@ -131,9 +136,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
|
||||
label: t("send_reschedule_request"),
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
|
||||
isDisabledRescheduling ||
|
||||
booking.seatsReferences.length > 0,
|
||||
isActionDisabled("reschedule_request", {
|
||||
...context,
|
||||
booking,
|
||||
isBookingInPast,
|
||||
isDisabledRescheduling,
|
||||
}) || booking.seatsReferences.length > 0,
|
||||
},
|
||||
isBookingFromRoutingForm
|
||||
? {
|
||||
@@ -229,12 +237,29 @@ export function shouldShowIndividualReportButton(context: BookingActionContext):
|
||||
}
|
||||
|
||||
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
|
||||
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
|
||||
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee } = context;
|
||||
|
||||
switch (actionId) {
|
||||
case "reschedule":
|
||||
case "reschedule_request":
|
||||
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
|
||||
// Only apply minimum reschedule notice restriction if user is NOT the organizer
|
||||
// If user is an attendee (or not authenticated), apply the restriction
|
||||
const isUserOrganizer =
|
||||
!isAttendee &&
|
||||
booking.loggedInUser?.userId &&
|
||||
booking.user?.id &&
|
||||
booking.loggedInUser.userId === booking.user.id;
|
||||
const isWithinMinimumNotice =
|
||||
!isUserOrganizer &&
|
||||
isWithinMinimumRescheduleNotice(
|
||||
new Date(booking.startTime),
|
||||
booking.eventType.minimumRescheduleNotice ?? null
|
||||
);
|
||||
return (
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
|
||||
isDisabledRescheduling ||
|
||||
isWithinMinimumNotice
|
||||
);
|
||||
case "cancel":
|
||||
return isDisabledCancelling || isBookingInPast;
|
||||
case "view_recordings":
|
||||
|
||||
@@ -257,8 +257,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-row md:space-x-3">
|
||||
<div className="bg-subtle mx-auto hidden h-12 w-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
|
||||
<Icon name="map-pin" className="text-emphasis h-6 w-6" />
|
||||
<div className="bg-subtle hidden h-10 w-10 shrink-0 justify-center rounded-full md:flex">
|
||||
<Icon name="map-pin" className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full md:pt-1">
|
||||
<DialogHeader title={t("edit_location")} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -59,6 +59,14 @@ export const ErrorPage: React.FC<Props> = (props) => {
|
||||
window.location.reload();
|
||||
props.reset?.();
|
||||
};
|
||||
|
||||
// useLayoutEffect runs synchronously before browser paint, ensuring it's set early
|
||||
useLayoutEffect(() => {
|
||||
if (statusCode && typeof window !== "undefined") {
|
||||
window.CalComPageStatus = statusCode.toString();
|
||||
}
|
||||
}, [statusCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-subtle flex h-screen">
|
||||
|
||||
@@ -44,12 +44,14 @@ const CustomEmailTextField = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`border-default mt-2 flex w-full items-center rounded-[10px] border ${
|
||||
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
|
||||
className={`mt-2 flex w-full items-center rounded-[10px] border transition-all ${
|
||||
inputFocus
|
||||
? "border-emphasis shadow-outline-gray-focused"
|
||||
: "border-default hover:border-emphasis shadow-outline-gray-rested"
|
||||
}`}>
|
||||
<Input
|
||||
{...formMethods.register(formMethodFieldName)}
|
||||
className="focus:ring-none! focus:shadow-none! flex-1 border-none bg-transparent px-3 py-1.5 text-sm outline-none"
|
||||
className="ring-0 focus:ring-0 focus:shadow-none! flex-1 border-none bg-transparent px-3 py-1.5 text-sm outline-none"
|
||||
data-testid={dataTestId}
|
||||
onFocus={() => setInputFocus(true)}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
hideOrganizerEmail: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
minimumRescheduleNotice: true,
|
||||
disableGuests: true,
|
||||
timeZone: true,
|
||||
profile: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
|
||||
|
||||
type Input = {
|
||||
active: boolean;
|
||||
@@ -19,6 +20,7 @@ type Output = {
|
||||
triggers: WebhookTriggerEvents[];
|
||||
secret?: string;
|
||||
payloadTemplate: string | undefined | null;
|
||||
version?: WebhookVersion;
|
||||
};
|
||||
|
||||
export const useOAuthClientWebhooks = (clientId: string) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
userId: true,
|
||||
responses: true,
|
||||
eventType: {
|
||||
select: {
|
||||
@@ -59,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
allowReschedulingPastBookings: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
minimumRescheduleNotice: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -122,17 +124,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
booking: {
|
||||
uid,
|
||||
status: booking.status,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
responses: booking.responses,
|
||||
userId: booking.userId,
|
||||
eventType: {
|
||||
disableRescheduling: !!eventType?.disableRescheduling,
|
||||
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
|
||||
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
|
||||
minimumRescheduleNotice: eventType.minimumRescheduleNotice,
|
||||
teamId: eventType.team?.id ?? null,
|
||||
},
|
||||
},
|
||||
eventUrl,
|
||||
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
|
||||
currentUserId: session?.user?.id ?? null,
|
||||
bookingSeat,
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ export default function Authorize() {
|
||||
}
|
||||
|
||||
if (client?.redirectUri) {
|
||||
window.location.href = `${client.redirectUri}${client.redirectUri.includes("?") ? "&" : "?"}${errorParams.toString()}`;
|
||||
window.location.href = `${client.redirectUri}${
|
||||
client.redirectUri.includes("?") ? "&" : "?"
|
||||
}${errorParams.toString()}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -89,6 +91,18 @@ export default function Authorize() {
|
||||
}
|
||||
}, [isPendingProfiles]);
|
||||
|
||||
// Auto-authorize trusted clients
|
||||
useEffect(() => {
|
||||
if (client?.isTrusted && selectedAccount) {
|
||||
generateAuthCodeMutation.mutate({
|
||||
clientId: client_id as string,
|
||||
scopes,
|
||||
codeChallenge: code_challenge || undefined,
|
||||
codeChallengeMethod: (code_challenge_method as "S256") || undefined,
|
||||
});
|
||||
}
|
||||
}, [client?.isTrusted, selectedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
@@ -108,9 +122,20 @@ export default function Authorize() {
|
||||
return <div>{t("unauthorized")}</div>;
|
||||
}
|
||||
|
||||
// Don't show UI for trusted clients, they'll auto-authorize
|
||||
if (client.isTrusted && selectedAccount) {
|
||||
return (
|
||||
<div className="flex justify-center pt-32">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-lg font-medium text-gray-700">Authorizing...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="bg-default border-subtle mt-2 max-w-xl rounded-md border px-9 pb-3 pt-2">
|
||||
<div className="bg-default border-subtle mt-2 max-w-xl rounded-md border px-9 pt-2 pb-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
@@ -127,7 +152,7 @@ export default function Authorize() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="px-5 pb-5 pt-3 text-center text-2xl font-bold tracking-tight">
|
||||
<h1 className="px-5 pt-3 pb-5 text-center text-2xl font-bold tracking-tight">
|
||||
{t("access_cal_account", { clientName: client.name, appName: APP_NAME })}
|
||||
</h1>
|
||||
<div className="mb-1 text-sm font-medium">{t("select_account_team")}</div>
|
||||
@@ -141,7 +166,7 @@ export default function Authorize() {
|
||||
defaultValue={selectedAccount || mappedProfiles[0]}
|
||||
options={mappedProfiles}
|
||||
/>
|
||||
<div className="mb-4 mt-5 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
|
||||
<div className="mt-5 mb-4 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
|
||||
<ul className="stack-y-4 text-sm">
|
||||
<li className="relative pl-5">
|
||||
<span className="absolute left-0">✓</span>{" "}
|
||||
@@ -166,7 +191,7 @@ export default function Authorize() {
|
||||
<span className="absolute left-0">✓</span> {t("access_bookings")}
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bg-subtle mb-8 mt-8 flex rounded-md p-3">
|
||||
<div className="bg-subtle mt-8 mb-8 flex rounded-md p-3">
|
||||
<div>
|
||||
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,40 @@
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const EMAIL_CLIENTS = [
|
||||
{
|
||||
name: "Gmail",
|
||||
icon: "/email-clients/gmail.svg",
|
||||
href: 'https://mail.google.com/mail/u/0/#search/%22api%2Fauth%2Fverify-email%22',
|
||||
},
|
||||
{
|
||||
name: "Outlook",
|
||||
icon: "/email-clients/outlook.svg",
|
||||
href: "https://outlook.live.com/mail/0/",
|
||||
},
|
||||
{
|
||||
name: "Yahoo",
|
||||
icon: "/email-clients/yahoo.svg",
|
||||
href: "https://mail.yahoo.com/d/search?p=Cal.com",
|
||||
},
|
||||
{
|
||||
name: "Proton",
|
||||
icon: "/email-clients/proton.svg",
|
||||
href: "https://mail.proton.me",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function VerifyEmailPage() {
|
||||
const { data } = useEmailVerifyCheck();
|
||||
@@ -47,17 +70,30 @@ function VerifyEmailPage() {
|
||||
description={t("verify_email_page_body", { email: session?.user?.email, appName: APP_NAME })}
|
||||
className="bg-default"
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="minimal"
|
||||
className="underline"
|
||||
loading={mutation.isPending}
|
||||
onClick={() => {
|
||||
posthog.capture("verify_email_resend_clicked");
|
||||
showToast(t("send_email"), "success");
|
||||
mutation.mutate();
|
||||
}}>
|
||||
{t("resend_email")}
|
||||
</Button>
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{EMAIL_CLIENTS.map(({ name, icon, href }) => (
|
||||
<Button
|
||||
key={name}
|
||||
color="secondary"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<img src={icon} alt={name} className="me-1 h-4 w-4" /> {name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={mutation.isPending}
|
||||
onClick={() => {
|
||||
posthog.capture("verify_email_resend_clicked");
|
||||
showToast(t("send_email"), "success");
|
||||
mutation.mutate();
|
||||
}}>
|
||||
{t("resend_email")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,40 +3,57 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { FilterSearchField, Select } from "@calcom/ui/components/form";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
|
||||
|
||||
interface BookingLogsViewProps {
|
||||
bookingUid: string;
|
||||
}
|
||||
|
||||
type TranslationComponent = {
|
||||
type: "link";
|
||||
href: string;
|
||||
};
|
||||
|
||||
type TranslationWithParams = {
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: Record<string, unknown> | null;
|
||||
source: string;
|
||||
displayJson?: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
|
||||
actor: {
|
||||
type: string;
|
||||
type: AuditActorType;
|
||||
displayName: string | null;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface BookingLogsFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
typeFilter: string | null;
|
||||
onTypeFilterChange: (value: string | null) => void;
|
||||
actorFilter: string | null;
|
||||
onActorFilterChange: (value: string | null) => void;
|
||||
typeOptions: Array<{ label: string; value: string }>;
|
||||
actorOptions: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
@@ -44,40 +61,35 @@ interface BookingLogsTimelineProps {
|
||||
logs: AuditLog[];
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case "CREATED":
|
||||
return "calendar";
|
||||
case "CANCELLED":
|
||||
case "REJECTED":
|
||||
return "ban";
|
||||
case "ACCEPTED":
|
||||
return "check";
|
||||
case "RESCHEDULED":
|
||||
case "RESCHEDULE_REQUESTED":
|
||||
return "pencil";
|
||||
case "REASSIGNMENT":
|
||||
case "ATTENDEE_ADDED":
|
||||
case "ATTENDEE_REMOVED":
|
||||
return "user-check";
|
||||
case "LOCATION_CHANGED":
|
||||
return "map-pin";
|
||||
case "HOST_NO_SHOW_UPDATED":
|
||||
case "ATTENDEE_NO_SHOW_UPDATED":
|
||||
return "ban";
|
||||
default:
|
||||
return "sparkles";
|
||||
}
|
||||
};
|
||||
const ACTION_ICON_MAP: Record<string, IconName> = {
|
||||
CREATED: "calendar",
|
||||
CANCELLED: "ban",
|
||||
REJECTED: "ban",
|
||||
ACCEPTED: "check",
|
||||
RESCHEDULED: "pencil",
|
||||
RESCHEDULE_REQUESTED: "pencil",
|
||||
REASSIGNMENT: "user-check",
|
||||
ATTENDEE_ADDED: "user-check",
|
||||
ATTENDEE_REMOVED: "user-check",
|
||||
LOCATION_CHANGED: "map-pin",
|
||||
HOST_NO_SHOW_UPDATED: "ban",
|
||||
ATTENDEE_NO_SHOW_UPDATED: "ban",
|
||||
} as const;
|
||||
|
||||
|
||||
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
|
||||
GUEST: "guest",
|
||||
ATTENDEE: "attendee",
|
||||
SYSTEM: null,
|
||||
USER: null,
|
||||
APP: null,
|
||||
} as const;
|
||||
|
||||
function BookingLogsFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
actorFilter,
|
||||
onActorFilterChange,
|
||||
typeOptions,
|
||||
actorOptions,
|
||||
}: BookingLogsFiltersProps) {
|
||||
const { t } = useLocale();
|
||||
@@ -93,18 +105,6 @@ function BookingLogsFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onTypeFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
@@ -113,13 +113,75 @@ function BookingLogsFilters({
|
||||
if (!option) return;
|
||||
onActorFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
|
||||
options={[{ label: t("all"), value: "" }, ...actorOptions]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the action display title with support for Trans component interpolation
|
||||
* Handles translations with embedded components (e.g., links) for proper i18n support
|
||||
*/
|
||||
function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWithParams }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (actionDisplayTitle.components?.length) {
|
||||
return (
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey={actionDisplayTitle.key}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<span key={comp.href} />
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{t(actionDisplayTitle.key, actionDisplayTitle.params)}</>;
|
||||
}
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function JsonViewer({ data }: JsonViewerProps) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(data, null, 2);
|
||||
const lines = jsonString.split("\n");
|
||||
const lineCount = lines.length;
|
||||
const lineNumberWidth = Math.max(2, Math.ceil(Math.log10(lineCount)) + 1);
|
||||
|
||||
return (
|
||||
<div className="bg-default p-3 rounded-md text-[10px] overflow-x-auto font-mono">
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<span
|
||||
className="text-subtle select-none text-right shrink-0"
|
||||
style={{ minWidth: `${lineNumberWidth}ch` }}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-default whitespace-pre">{line || " "}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
const { t } = useLocale();
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -157,15 +219,14 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
{logs.map((log, index) => {
|
||||
const isLast = index === logs.length - 1;
|
||||
const isExpanded = expandedLogIds.has(log.id);
|
||||
const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
|
||||
const showJson = showJsonMap[log.id] || false;
|
||||
|
||||
const actorRole = ACTOR_ROLE_LABEL_MAP[log.actor.type] ?? null;
|
||||
return (
|
||||
<div key={log.id} className="flex gap-1">
|
||||
<div className="flex flex-col items-center self-stretch">
|
||||
<div className="pt-2 shrink-0">
|
||||
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
|
||||
<Icon name={getActionIcon(log.action)} className="h-3 w-3 text-subtle" />
|
||||
<Icon name={ACTION_ICON_MAP[log.action] ?? "sparkles"} className="h-3 w-3 text-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
@@ -178,78 +239,88 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-emphasis leading-4">
|
||||
{actionDisplay}
|
||||
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
||||
<span>{log.actor.displayName}</span>
|
||||
{log.actor.displayAvatar && (
|
||||
<Avatar
|
||||
size="xs"
|
||||
imageSrc={log.actor.displayAvatar}
|
||||
alt={log.actor.displayName || ""}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{log.actor.displayName}
|
||||
{actorRole && <span>{` (${t(actorRole)})`}</span>}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(log.timestamp).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<div className="bg-muted rounded-[10px] py-1">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
||||
className="w-full justify-start text-xs font-medium text-subtle h-6">
|
||||
{isExpanded ? t("hide_details") : t("show_details")}
|
||||
</Button>
|
||||
<div className="bg-muted rounded-lg border border-muted">
|
||||
<div className="py-1">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
||||
className="justify-start text-xs font-medium text-subtle h-6">
|
||||
{isExpanded ? t("hide_details") : t("show_details")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-default rounded-lg m-0.5 text-xs">
|
||||
{/* 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 min-w-[80px]">{t(field.labelKey)}</span>
|
||||
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("source")}</span>
|
||||
<span className="text-default">{log.source}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">
|
||||
{t("timestamp")}
|
||||
</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
{log.displayJson && Object.keys(log.displayJson).length > 0 && (
|
||||
<div>
|
||||
<div className="flex flex-col items-start gap-2 py-1 px-3 border-b border-subtle">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleJson(log.id)}
|
||||
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
||||
className="-ml-3 h-6 px-2 font-medium text-xs">
|
||||
{t("json")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{showJson && <JsonViewer data={log.displayJson} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 px-3">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
|
||||
<span className="text-default">{log.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">
|
||||
{t("timestamp")}
|
||||
</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleJson(log.id)}
|
||||
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
||||
className="mb-1 h-6 px-0 font-medium">
|
||||
{t("json")}
|
||||
</Button>
|
||||
{showJson && (
|
||||
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -258,16 +329,55 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function useBookingLogsFilters(
|
||||
auditLogs: AuditLog[],
|
||||
searchTerm: string,
|
||||
actorFilter: string | null
|
||||
) {
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesActor = !actorFilter || log.actor.displayName === actorFilter;
|
||||
|
||||
return matchesSearch && matchesActor;
|
||||
});
|
||||
|
||||
const uniqueActorNames = Array.from(
|
||||
new Set(
|
||||
auditLogs
|
||||
.map((log) => log.actor.displayName)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
)
|
||||
);
|
||||
|
||||
const actorOptions = uniqueActorNames.map((actorName) => ({
|
||||
label: actorName,
|
||||
value: actorName,
|
||||
}));
|
||||
|
||||
return { filteredLogs, actorOptions };
|
||||
}
|
||||
|
||||
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [actorFilter, setActorFilter] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
const auditLogs = data?.auditLogs || [];
|
||||
|
||||
const { filteredLogs, actorOptions } = useBookingLogsFilters(
|
||||
auditLogs,
|
||||
searchTerm,
|
||||
actorFilter
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -293,44 +403,13 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const auditLogs = data?.auditLogs || [];
|
||||
|
||||
// Apply filters
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter || log.action === typeFilter;
|
||||
const matchesActor = !actorFilter || log.actor.type === actorFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesActor;
|
||||
});
|
||||
|
||||
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
|
||||
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
|
||||
|
||||
const typeOptions = uniqueTypes.map((type) => ({
|
||||
label: t(`audit_action.${type.toLowerCase()}`),
|
||||
value: type,
|
||||
}));
|
||||
|
||||
const actorOptions = uniqueActorTypes.map((actorType) => ({
|
||||
label: actorType,
|
||||
value: actorType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BookingLogsFilters
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
actorFilter={actorFilter}
|
||||
onActorFilterChange={setActorFilter}
|
||||
typeOptions={typeOptions}
|
||||
actorOptions={actorOptions}
|
||||
/>
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export function buildFilterColumns({ t, permissions, status }: BuildFilterColumn
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
import { useBookingCalendarData } from "~/bookings/hooks/useBookingCalendarData";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useCalendarAllowedFilters } from "~/bookings/hooks/useCalendarAllowedFilters";
|
||||
import { useCalendarAutoSelector } from "~/bookings/hooks/useCalendarAutoSelector";
|
||||
import { useCalendarNavigationCapabilities } from "~/bookings/hooks/useCalendarNavigationCapabilities";
|
||||
import { useCurrentWeekStart } from "~/bookings/hooks/useCurrentWeekStart";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
@@ -36,6 +38,7 @@ interface BookingCalendarContainerProps {
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
bookingsV3Enabled: boolean;
|
||||
}
|
||||
|
||||
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
@@ -48,20 +51,39 @@ interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
isPending: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
hasNextPage: boolean;
|
||||
isFetched: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
}
|
||||
|
||||
function BookingCalendarInner({
|
||||
status,
|
||||
permissions,
|
||||
bookingsV3Enabled,
|
||||
data,
|
||||
allowedFilterIds,
|
||||
hasError,
|
||||
errorMessage,
|
||||
hasNextPage,
|
||||
isFetched,
|
||||
isFetchingNextPage,
|
||||
}: BookingCalendarInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const rowData = useBookingCalendarData({ data, status });
|
||||
|
||||
// Extract bookings from table data
|
||||
const bookings = useMemo(() => {
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [rowData]);
|
||||
|
||||
// Handle auto-selection for calendar view
|
||||
useCalendarAutoSelector(bookings, hasNextPage, isFetched, isFetchingNextPage);
|
||||
|
||||
const goToPreviousWeek = () => {
|
||||
setCurrentWeekStart(currentWeekStart.subtract(1, "week"));
|
||||
};
|
||||
@@ -86,8 +108,6 @@ function BookingCalendarInner({
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const rowData = useBookingCalendarData({ data, status });
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
@@ -99,13 +119,6 @@ function BookingCalendarInner({
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data
|
||||
const bookings = useMemo(() => {
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [rowData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -132,7 +145,7 @@ function BookingCalendarInner({
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ViewToggleButton />
|
||||
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
|
||||
</div>
|
||||
</div>
|
||||
{hasError && ErrorView ? (
|
||||
@@ -158,7 +171,7 @@ function BookingCalendarInner({
|
||||
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
const { canReadOthersBookings } = props.permissions;
|
||||
const { userIds } = useBookingFilters();
|
||||
const { currentWeekStart } = useCurrentWeekStart();
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const allowedFilterIds = useCalendarAllowedFilters({
|
||||
canReadOthersBookings,
|
||||
@@ -182,8 +195,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
}
|
||||
);
|
||||
|
||||
const { isFetched, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
|
||||
// Automatically fetch all pages until no more data
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
@@ -208,8 +222,17 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
|
||||
const bookings = useMemo(() => data?.bookings ?? [], [data?.bookings]);
|
||||
|
||||
// Create navigation capabilities for calendar view
|
||||
// This hook handles probe queries and prefetching internally
|
||||
const capabilities = useCalendarNavigationCapabilities({
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
userWeekStart,
|
||||
filters: { statuses: STATUSES, userIds },
|
||||
});
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
|
||||
<BookingCalendarInner
|
||||
{...props}
|
||||
data={data}
|
||||
@@ -217,6 +240,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetched={isFetched}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export function BookingCalendarView({
|
||||
onWeekStartChange,
|
||||
}: BookingCalendarViewProps) {
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
||||
const { timezone } = useTimePreferences();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
const { bannersHeight } = useBanners();
|
||||
@@ -91,6 +92,7 @@ export function BookingCalendarView({
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
selectedBookingUid={selectedBookingUid}
|
||||
onEventClick={(event) => {
|
||||
const bookingUid = event.options?.bookingUid;
|
||||
if (bookingUid) {
|
||||
|
||||
@@ -42,10 +42,7 @@ import { BookingActionsDropdown } from "../../../components/booking/actions/Book
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import {
|
||||
useBookingDetailsSheetStore,
|
||||
useBookingDetailsSheetStoreApi,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
@@ -103,35 +100,38 @@ function BookingDetailsSheetInner({
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
enabled: Boolean(booking.rescheduled || booking.fromReschedule),
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// Get navigation state directly from the store
|
||||
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
|
||||
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
// Get navigation state from the store in a single selector
|
||||
const navigation = useBookingDetailsSheetStore((state) => {
|
||||
const hasNextInArray = state.hasNextInArray();
|
||||
const hasPreviousInArray = state.hasPreviousInArray();
|
||||
const isLastInArray = state.isLastInArray();
|
||||
const isFirstInArray = state.isFirstInArray();
|
||||
|
||||
return {
|
||||
navigateNext: state.navigateNext,
|
||||
navigatePrevious: state.navigatePrevious,
|
||||
isTransitioning: state.isTransitioning,
|
||||
setSelectedBookingUid: state.setSelectedBookingUid,
|
||||
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
|
||||
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
|
||||
};
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedBookingUid(null);
|
||||
navigation.setSelectedBookingUid(null);
|
||||
};
|
||||
|
||||
const storeApi = useBookingDetailsSheetStoreApi();
|
||||
|
||||
const handleNext = () => {
|
||||
const nextUid = storeApi.getState().getNextBookingUid();
|
||||
if (nextUid !== null) {
|
||||
setSelectedBookingUid(nextUid);
|
||||
}
|
||||
navigation.navigateNext();
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const prevUid = storeApi.getState().getPreviousBookingUid();
|
||||
if (prevUid !== null) {
|
||||
setSelectedBookingUid(prevUid);
|
||||
}
|
||||
navigation.navigatePrevious();
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
@@ -214,7 +214,7 @@ function BookingDetailsSheetInner({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!hasPrevious}
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
@@ -225,7 +225,7 @@ function BookingDetailsSheetInner({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!hasNext}
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
@@ -289,6 +289,8 @@ function BookingDetailsSheetInner({
|
||||
customResponses={customResponses}
|
||||
bookingFields={booking.eventType?.bookingFields}
|
||||
/>
|
||||
|
||||
<TrackingSection tracking={bookingDetails?.tracking} />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
@@ -841,6 +843,43 @@ function BookingHeaderBadges({
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingSection({
|
||||
tracking,
|
||||
}: {
|
||||
tracking?: {
|
||||
utm_source: string | null;
|
||||
utm_medium: string | null;
|
||||
utm_campaign: string | null;
|
||||
utm_term: string | null;
|
||||
utm_content: string | null;
|
||||
} | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!tracking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
|
||||
|
||||
if (utmEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("utm_params")}>
|
||||
<div className="text-default text-sm">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
className,
|
||||
|
||||
@@ -25,6 +25,8 @@ 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 { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
|
||||
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
@@ -77,11 +79,13 @@ interface BookingListInnerProps extends BookingListContainerProps {
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
totalRowCount?: number;
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
}
|
||||
|
||||
function BookingListInner({
|
||||
status,
|
||||
permissions,
|
||||
bookings,
|
||||
bookingsV3Enabled,
|
||||
data,
|
||||
isPending,
|
||||
@@ -95,6 +99,9 @@ function BookingListInner({
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
// Handle auto-selection for list view
|
||||
useListAutoSelector(bookings);
|
||||
|
||||
const ErrorView = errorMessage ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
|
||||
) : undefined;
|
||||
@@ -180,7 +187,7 @@ function BookingListInner({
|
||||
<div className="hidden grow md:block" />
|
||||
|
||||
<DataTableSegment.Select shortLabel />
|
||||
{bookingsV3Enabled && <ViewToggleButton />}
|
||||
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
|
||||
</div>
|
||||
{displayedFilterCount > 0 && showFilters && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
@@ -221,12 +228,13 @@ function BookingListInner({
|
||||
}
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset } = useDataTable();
|
||||
const { limit, offset, setPageIndex } = useDataTable();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
// Build query input once - shared between query and prefetching
|
||||
const queryInput = useMemo(
|
||||
() => ({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
@@ -242,17 +250,40 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
}),
|
||||
[
|
||||
limit,
|
||||
offset,
|
||||
props.status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
dateRange,
|
||||
]
|
||||
);
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
|
||||
|
||||
// Always call the hook and provide navigation capabilities
|
||||
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
|
||||
const capabilities = useListNavigationCapabilities({
|
||||
limit,
|
||||
offset,
|
||||
totalCount: query.data?.totalCount,
|
||||
setPageIndex,
|
||||
queryInput,
|
||||
});
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
data={query.data}
|
||||
@@ -260,6 +291,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
totalRowCount={query.data?.totalCount}
|
||||
bookings={bookings}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -8,14 +7,15 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { viewParser, type BookingView } from "../lib/viewParser";
|
||||
import { useBookingsView } from "../hooks/useBookingsView";
|
||||
|
||||
export function ViewToggleButton() {
|
||||
type ViewToggleButtonProps = {
|
||||
bookingsV3Enabled: boolean;
|
||||
};
|
||||
|
||||
export function ViewToggleButton({ bookingsV3Enabled }: ViewToggleButtonProps) {
|
||||
const { t } = useLocale();
|
||||
const [view, setView] = useQueryState(
|
||||
"view",
|
||||
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
|
||||
);
|
||||
const [view, setView] = useBookingsView({ bookingsV3Enabled });
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,9 +33,9 @@ export function ViewToggleButton() {
|
||||
<div className="hidden sm:block">
|
||||
<ToggleGroup
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
onValueChange={(value: "list" | "calendar") => {
|
||||
if (!value) return;
|
||||
setView(value as BookingView);
|
||||
setView(value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
|
||||
@@ -98,7 +98,7 @@ export function useBookingListColumns({
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,6 +26,18 @@ export function useBookingListData({
|
||||
userTimeZone?: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Build a Map for recurringInfo lookups
|
||||
const recurringInfoMap = useMemo(() => {
|
||||
const map = new Map<string, NonNullable<typeof data>["recurringInfo"][number]>();
|
||||
for (const info of data?.recurringInfo ?? []) {
|
||||
if (info.recurringEventId) {
|
||||
map.set(info.recurringEventId, info);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [data?.recurringInfo]);
|
||||
|
||||
/**
|
||||
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
@@ -70,11 +82,11 @@ export function useBookingListData({
|
||||
data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined,
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [data, status, userTimeZone]);
|
||||
}, [data?.bookings, recurringInfoMap, status, userTimeZone]);
|
||||
|
||||
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
|
||||
const bookingsToday = useMemo<BookingRowData[]>(() => {
|
||||
@@ -88,10 +100,10 @@ export function useBookingListData({
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined,
|
||||
isToday: true,
|
||||
}));
|
||||
}, [data, userTimeZone]);
|
||||
}, [data?.bookings, recurringInfoMap, userTimeZone]);
|
||||
|
||||
// Combine data with section separators for "upcoming" tab
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
const MARKER_CLASS = "bookings-shell-heading";
|
||||
|
||||
export function useBookingsShellHeadingVisibility({ visible }: { visible: boolean }) {
|
||||
useLayoutEffect(() => {
|
||||
const marker = document.querySelector(`.${MARKER_CLASS}`);
|
||||
if (!marker) return;
|
||||
|
||||
const headerWrapper = marker.closest("header")?.parentElement;
|
||||
if (!headerWrapper) return;
|
||||
|
||||
if (visible) {
|
||||
headerWrapper.classList.remove("hidden");
|
||||
} else {
|
||||
headerWrapper.classList.add("hidden");
|
||||
}
|
||||
|
||||
return () => {
|
||||
headerWrapper.classList.remove("hidden");
|
||||
};
|
||||
}, [visible]);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
const STORAGE_KEY = "bookings-preferred-view";
|
||||
|
||||
type BookingView = "list" | "calendar";
|
||||
|
||||
const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: BookingView) => value,
|
||||
});
|
||||
|
||||
// Create a store for localStorage value
|
||||
const createLocalStorageStore = () => {
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = (): BookingView => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "list" || stored === "calendar") {
|
||||
return stored;
|
||||
}
|
||||
return "list";
|
||||
};
|
||||
|
||||
const getServerSnapshot = (): BookingView => {
|
||||
return "list";
|
||||
};
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return { subscribe, getSnapshot, getServerSnapshot, notify };
|
||||
};
|
||||
|
||||
const localStorageStore = createLocalStorageStore();
|
||||
|
||||
type UseBookingsViewOptions = {
|
||||
bookingsV3Enabled: boolean;
|
||||
};
|
||||
|
||||
export function useBookingsView({ bookingsV3Enabled }: UseBookingsViewOptions) {
|
||||
// Always use "list" as the default for useQueryState to keep instances in sync
|
||||
const [_view, setView] = useQueryState("view", viewParser.withDefault("list"));
|
||||
|
||||
// Track if we've completed the initial sync to prevent race conditions
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Read from localStorage using useSyncExternalStore
|
||||
const storedView = useSyncExternalStore(
|
||||
localStorageStore.subscribe,
|
||||
localStorageStore.getSnapshot,
|
||||
localStorageStore.getServerSnapshot
|
||||
);
|
||||
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = bookingsV3Enabled ? _view : "list";
|
||||
|
||||
// Sync localStorage value to URL on initial mount
|
||||
useEffect(() => {
|
||||
// Only sync if there's no URL parameter AND localStorage has a non-default value
|
||||
const urlHasViewParam =
|
||||
typeof window !== "undefined" && new URLSearchParams(window.location.search).has("view");
|
||||
|
||||
if (!urlHasViewParam && storedView !== "list" && _view !== storedView) {
|
||||
setView(storedView);
|
||||
} else {
|
||||
// No sync needed, mark as initialized
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Mark as initialized when _view matches storedView after initial sync
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current && _view === storedView) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}, [_view, storedView]);
|
||||
|
||||
// Sync to localStorage when view changes (only if initialized)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return;
|
||||
|
||||
if (bookingsV3Enabled && view && view !== storedView) {
|
||||
localStorage.setItem(STORAGE_KEY, view);
|
||||
localStorageStore.notify(); // Notify all subscribers
|
||||
}
|
||||
}, [view, storedView, bookingsV3Enabled]);
|
||||
|
||||
return [view, setView] as const;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingsGetOutput } from "../types";
|
||||
|
||||
/**
|
||||
* Calendar-specific auto-selection logic hook.
|
||||
* Handles auto-selecting bookings when navigating across weeks in calendar view.
|
||||
*
|
||||
* - For "first": Selects as soon as the first page loads (immediate)
|
||||
* - For "last": Waits for all pages to load to ensure we get the actual last booking
|
||||
*/
|
||||
export function useCalendarAutoSelector(
|
||||
bookings: BookingsGetOutput["bookings"],
|
||||
hasNextPage: boolean,
|
||||
isFetched: boolean,
|
||||
isFetchingNextPage: boolean
|
||||
) {
|
||||
const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection);
|
||||
const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning);
|
||||
const bookingsRef = useRef(bookings);
|
||||
|
||||
useEffect(() => {
|
||||
// Early return if no pending selection
|
||||
if (!pendingSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBookingsChanged = bookings !== bookingsRef.current;
|
||||
bookingsRef.current = bookings;
|
||||
|
||||
if (!hasBookingsChanged) return;
|
||||
|
||||
if (pendingSelection === "first" && isFetched && bookings.length === 0) {
|
||||
// data fetching is finished but there is no booking to select
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// For "first", we can select immediately when the first page arrives
|
||||
if (pendingSelection === "first" && bookings.length > 0) {
|
||||
setSelectedBookingUid(bookings[0].uid);
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// For "last", wait until all pages are loaded to ensure we get the actual last booking
|
||||
if (pendingSelection === "last") {
|
||||
const isAllDataLoaded = !hasNextPage && !isFetchingNextPage;
|
||||
|
||||
if (isAllDataLoaded && bookings.length === 0) {
|
||||
// data fetching is finished but there is no booking to select
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for all data to load AND for bookings to actually arrive
|
||||
if (!isAllDataLoaded || bookings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastBooking = bookings[bookings.length - 1];
|
||||
setSelectedBookingUid(lastBooking.uid);
|
||||
|
||||
// Clear transition state and pending selection after handling
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
}
|
||||
}, [
|
||||
bookings,
|
||||
hasNextPage,
|
||||
isFetched,
|
||||
isFetchingNextPage,
|
||||
pendingSelection,
|
||||
setSelectedBookingUid,
|
||||
clearPendingSelection,
|
||||
setIsTransitioning,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { getWeekStart } from "../lib/weekUtils";
|
||||
import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
import { useNearestFutureBooking } from "./useNearestFutureBooking";
|
||||
import { useNearestPastBooking } from "./useNearestPastBooking";
|
||||
|
||||
interface UseCalendarNavigationCapabilitiesProps {
|
||||
currentWeekStart: Dayjs;
|
||||
setCurrentWeekStart: (date: Dayjs) => void;
|
||||
userWeekStart: number;
|
||||
/** Filters to use for probe queries and prefetching */
|
||||
filters: {
|
||||
statuses: BookingListingStatus[];
|
||||
userIds?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar view navigation capabilities adapter.
|
||||
* Provides week-based navigation logic for the booking details sheet.
|
||||
*
|
||||
* This hook:
|
||||
* - Uses probe queries to find nearest bookings in each direction
|
||||
* - Jumps directly to the week containing the nearest booking (not just adjacent week)
|
||||
* - Disables navigation buttons when no bookings exist in that direction
|
||||
* - Prefetches booking data for the target weeks to improve navigation performance
|
||||
*/
|
||||
export function useCalendarNavigationCapabilities({
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
userWeekStart,
|
||||
filters,
|
||||
}: UseCalendarNavigationCapabilitiesProps): NavigationCapabilities {
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
// Probe queries for navigation - find nearest bookings in each direction
|
||||
const { nearestBooking: nearestFutureBooking } = useNearestFutureBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
});
|
||||
|
||||
const { nearestBooking: nearestPastBooking } = useNearestPastBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
});
|
||||
|
||||
const hasFutureBooking = !!nearestFutureBooking;
|
||||
const hasPastBooking = !!nearestPastBooking;
|
||||
const nextBookingDate = nearestFutureBooking?.startTime?.toString() ?? null;
|
||||
const prevBookingDate = nearestPastBooking?.startTime?.toString() ?? null;
|
||||
|
||||
// Calculate target week starts for prefetching
|
||||
const nextWeekStart = useMemo(() => {
|
||||
if (!nextBookingDate) return null;
|
||||
return getWeekStart(dayjs(nextBookingDate), userWeekStart);
|
||||
}, [nextBookingDate, userWeekStart]);
|
||||
|
||||
const prevWeekStart = useMemo(() => {
|
||||
if (!prevBookingDate) return null;
|
||||
return getWeekStart(dayjs(prevBookingDate), userWeekStart);
|
||||
}, [prevBookingDate, userWeekStart]);
|
||||
|
||||
// Prefetch bookings for the next week with bookings
|
||||
// Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer
|
||||
useEffect(() => {
|
||||
if (!nextWeekStart) return;
|
||||
|
||||
trpcUtils.viewer.bookings.get.prefetchInfinite({
|
||||
limit: 100,
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: nextWeekStart.startOf("day").toISOString(),
|
||||
beforeEndDate: nextWeekStart.add(6, "day").endOf("day").toISOString(),
|
||||
},
|
||||
});
|
||||
}, [nextWeekStart, filters.statuses, filters.userIds, trpcUtils]);
|
||||
|
||||
// Prefetch bookings for the previous week with bookings
|
||||
// Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer
|
||||
useEffect(() => {
|
||||
if (!prevWeekStart) return;
|
||||
|
||||
trpcUtils.viewer.bookings.get.prefetchInfinite({
|
||||
limit: 100,
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: prevWeekStart.startOf("day").toISOString(),
|
||||
beforeEndDate: prevWeekStart.add(6, "day").endOf("day").toISOString(),
|
||||
},
|
||||
});
|
||||
}, [prevWeekStart, filters.statuses, filters.userIds, trpcUtils]);
|
||||
|
||||
const canNavigateToNextPeriod = useCallback(() => hasFutureBooking, [hasFutureBooking]);
|
||||
|
||||
const canNavigateToPreviousPeriod = useCallback(() => hasPastBooking, [hasPastBooking]);
|
||||
|
||||
const requestNextPeriod = useCallback(() => {
|
||||
if (!nextBookingDate) return;
|
||||
const targetWeekStart = getWeekStart(dayjs(nextBookingDate), userWeekStart);
|
||||
setCurrentWeekStart(targetWeekStart);
|
||||
}, [nextBookingDate, setCurrentWeekStart, userWeekStart]);
|
||||
|
||||
const requestPreviousPeriod = useCallback(() => {
|
||||
if (!prevBookingDate) return;
|
||||
const targetWeekStart = getWeekStart(dayjs(prevBookingDate), userWeekStart);
|
||||
setCurrentWeekStart(targetWeekStart);
|
||||
}, [prevBookingDate, setCurrentWeekStart, userWeekStart]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
canNavigateToNextPeriod,
|
||||
canNavigateToPreviousPeriod,
|
||||
requestNextPeriod,
|
||||
requestPreviousPeriod,
|
||||
}),
|
||||
[canNavigateToNextPeriod, canNavigateToPreviousPeriod, requestNextPeriod, requestPreviousPeriod]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingsGetOutput } from "../types";
|
||||
|
||||
/**
|
||||
* List-specific auto-selection logic hook.
|
||||
* Handles auto-selecting bookings when navigating across pages in list view.
|
||||
* For list view, data is loaded per page, so we can select immediately when bookings change.
|
||||
*/
|
||||
export function useListAutoSelector(bookings: BookingsGetOutput["bookings"]) {
|
||||
const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection);
|
||||
const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning);
|
||||
const bookingsRef = useRef(bookings);
|
||||
|
||||
useEffect(() => {
|
||||
const hasBookingsChanged = bookings !== bookingsRef.current;
|
||||
// Always track current bookings to avoid stale ref when pendingSelection is later set
|
||||
bookingsRef.current = bookings;
|
||||
|
||||
// Early return if no pending selection
|
||||
if (!pendingSelection) return;
|
||||
|
||||
if (!hasBookingsChanged) return;
|
||||
|
||||
if (bookings.length > 0) {
|
||||
const bookingToSelect = pendingSelection === "first" ? bookings[0] : bookings[bookings.length - 1];
|
||||
setSelectedBookingUid(bookingToSelect.uid);
|
||||
}
|
||||
|
||||
// Always clear transition state and pending selection after handling
|
||||
setIsTransitioning(false);
|
||||
clearPendingSelection();
|
||||
}, [bookings, pendingSelection, setSelectedBookingUid, clearPendingSelection, setIsTransitioning]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore";
|
||||
|
||||
interface UseListNavigationCapabilitiesProps {
|
||||
limit: number;
|
||||
offset: number;
|
||||
totalCount: number | undefined;
|
||||
setPageIndex: (index: number) => void;
|
||||
queryInput: Parameters<typeof trpc.viewer.bookings.get.useQuery>[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* List view navigation capabilities adapter.
|
||||
* Provides pagination-specific navigation logic for the booking details sheet.
|
||||
*
|
||||
* This hook:
|
||||
* - Calculates page boundaries based on limit/offset
|
||||
* - Prefetches the next page when available (using the same query params as parent)
|
||||
* - Provides methods to navigate between pages
|
||||
*/
|
||||
export function useListNavigationCapabilities({
|
||||
limit,
|
||||
offset,
|
||||
totalCount,
|
||||
setPageIndex,
|
||||
queryInput,
|
||||
}: UseListNavigationCapabilitiesProps): NavigationCapabilities {
|
||||
const trpcUtils = trpc.useUtils();
|
||||
const currentPageIndex = limit > 0 ? offset / limit : 0;
|
||||
|
||||
// Calculate if there are more pages
|
||||
const hasNextPage = useMemo(() => {
|
||||
if (!totalCount) return false;
|
||||
return offset + limit < totalCount;
|
||||
}, [offset, limit, totalCount]);
|
||||
|
||||
const hasPreviousPage = offset > 0;
|
||||
|
||||
// Build query params for next page by reusing parent's query input
|
||||
const nextPageParams = useMemo(
|
||||
() => ({
|
||||
...queryInput,
|
||||
offset: (currentPageIndex + 1) * limit,
|
||||
}),
|
||||
[queryInput, currentPageIndex, limit]
|
||||
);
|
||||
|
||||
// Prefetch next page when it exists
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
trpcUtils.viewer.bookings.get.prefetch(nextPageParams);
|
||||
}
|
||||
}, [hasNextPage, nextPageParams, trpcUtils]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
canNavigateToNextPeriod: () => hasNextPage,
|
||||
canNavigateToPreviousPeriod: () => hasPreviousPage,
|
||||
|
||||
requestNextPeriod: () => {
|
||||
if (!hasNextPage) return;
|
||||
setPageIndex(currentPageIndex + 1);
|
||||
},
|
||||
|
||||
requestPreviousPeriod: () => {
|
||||
if (!hasPreviousPage) return;
|
||||
setPageIndex(currentPageIndex - 1);
|
||||
},
|
||||
}),
|
||||
[hasNextPage, hasPreviousPage, currentPageIndex, setPageIndex]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
|
||||
interface UseNearestFutureBookingProps {
|
||||
currentWeekStart: Dayjs;
|
||||
filters: {
|
||||
statuses: BookingListingStatus[];
|
||||
userIds?: number[];
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe hook to find the nearest future booking after the current week.
|
||||
* Used for calendar view navigation to determine if there are any bookings
|
||||
* in the future and to jump directly to the week containing that booking.
|
||||
*
|
||||
* Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches
|
||||
* 1 booking (the nearest one) to minimize data transfer.
|
||||
*/
|
||||
export function useNearestFutureBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
enabled = true,
|
||||
}: UseNearestFutureBookingProps) {
|
||||
// Search from the end of current week to NAVIGATION_PROBE_WINDOW_MONTHS months ahead
|
||||
const afterDate = currentWeekStart.add(1, "week").startOf("day");
|
||||
const beforeDate = currentWeekStart.add(NAVIGATION_PROBE_WINDOW_MONTHS, "month").endOf("day");
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: afterDate.toISOString(),
|
||||
beforeEndDate: beforeDate.toISOString(),
|
||||
},
|
||||
limit: 1, // Only need the nearest one
|
||||
// Default sort for "upcoming" status is ASC, which gives us the nearest future booking
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
nearestBooking: query.data?.bookings[0] ?? null,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
|
||||
interface UseNearestPastBookingProps {
|
||||
currentWeekStart: Dayjs;
|
||||
filters: {
|
||||
statuses: BookingListingStatus[];
|
||||
userIds?: number[];
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe hook to find the nearest past booking before the current week.
|
||||
* Used for calendar view navigation to determine if there are any bookings
|
||||
* in the past and to jump directly to the week containing that booking.
|
||||
*
|
||||
* Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches
|
||||
* 1 booking (the nearest one) to minimize data transfer.
|
||||
* Uses descending sort to get the closest past booking first.
|
||||
*/
|
||||
export function useNearestPastBooking({
|
||||
currentWeekStart,
|
||||
filters,
|
||||
enabled = true,
|
||||
}: UseNearestPastBookingProps) {
|
||||
// Search from NAVIGATION_PROBE_WINDOW_MONTHS months ago to the start of current week
|
||||
const afterDate = currentWeekStart.subtract(NAVIGATION_PROBE_WINDOW_MONTHS, "month").startOf("day");
|
||||
const beforeDate = currentWeekStart.startOf("day");
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
filters: {
|
||||
statuses: filters.statuses,
|
||||
userIds: filters.userIds,
|
||||
afterStartDate: afterDate.toISOString(),
|
||||
beforeEndDate: beforeDate.toISOString(),
|
||||
},
|
||||
limit: 1, // Only need the nearest one
|
||||
sort: { sortStart: "desc" }, // Get the closest past booking first
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
nearestBooking: query.data?.bookings[0] ?? null,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Number of months to look ahead/behind when probing for nearest bookings
|
||||
* in calendar view navigation. This determines how far the navigation
|
||||
* probe queries will search for bookings.
|
||||
*/
|
||||
export const NAVIGATION_PROBE_WINDOW_MONTHS = 3;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createParser } from "nuqs";
|
||||
|
||||
export const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
export type BookingView = "list" | "calendar";
|
||||
@@ -1,27 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { createStore, useStore } from "zustand";
|
||||
|
||||
import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
export type PendingSelectionType = "first" | "last" | null;
|
||||
|
||||
/**
|
||||
* Capabilities interface for view-specific navigation logic.
|
||||
* This allows different views (list/calendar) to provide their own
|
||||
* navigation implementations without the core store knowing about them.
|
||||
*/
|
||||
export interface NavigationCapabilities {
|
||||
/**
|
||||
* Check if navigation to the previous period is possible
|
||||
* (e.g., previous page in list view, previous week in calendar view)
|
||||
*/
|
||||
canNavigateToPreviousPeriod: () => boolean;
|
||||
|
||||
/**
|
||||
* Check if navigation to the next period is possible
|
||||
* (e.g., next page in list view, next week in calendar view)
|
||||
*/
|
||||
canNavigateToNextPeriod: () => boolean;
|
||||
|
||||
/**
|
||||
* Request navigation to the previous period.
|
||||
* This should trigger the view state update (e.g., page index change).
|
||||
* The parent component will handle fetching data and updating bookings.
|
||||
*/
|
||||
requestPreviousPeriod: () => void;
|
||||
|
||||
/**
|
||||
* Request navigation to the next period.
|
||||
* This should trigger the view state update (e.g., page index change).
|
||||
* The parent component will handle fetching data and updating bookings.
|
||||
*/
|
||||
requestNextPeriod: () => void;
|
||||
}
|
||||
|
||||
interface BookingDetailsSheetStore {
|
||||
// State
|
||||
// Core state (view-agnostic)
|
||||
selectedBookingUid: string | null;
|
||||
bookings: BookingOutput[];
|
||||
isTransitioning: boolean;
|
||||
pendingSelection: PendingSelectionType;
|
||||
|
||||
// Actions
|
||||
// Injected capabilities (provided by adapters)
|
||||
capabilities: NavigationCapabilities | null;
|
||||
|
||||
// Core actions
|
||||
setSelectedBookingUid: (uid: string | null) => void;
|
||||
setBookings: (bookings: BookingOutput[]) => void;
|
||||
setCapabilities: (capabilities: NavigationCapabilities | null) => void;
|
||||
clearSelection: () => void;
|
||||
clearPendingSelection: () => void;
|
||||
setIsTransitioning: (isTransitioning: boolean) => void;
|
||||
|
||||
// Computed getters (used via selectors)
|
||||
// Navigation methods (delegates to capabilities)
|
||||
navigateNext: () => Promise<void>;
|
||||
navigatePrevious: () => Promise<void>;
|
||||
|
||||
// Simple getters (no view-specific logic)
|
||||
getSelectedBooking: () => BookingOutput | null;
|
||||
getNextBookingUid: () => string | null;
|
||||
getPreviousBookingUid: () => string | null;
|
||||
hasNext: () => boolean;
|
||||
hasPrevious: () => boolean;
|
||||
getCurrentIndex: () => number;
|
||||
hasNextInArray: () => boolean;
|
||||
hasPreviousInArray: () => boolean;
|
||||
isFirstInArray: () => boolean;
|
||||
isLastInArray: () => boolean;
|
||||
}
|
||||
|
||||
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
|
||||
@@ -31,53 +79,90 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) =
|
||||
// Initial state
|
||||
selectedBookingUid: null,
|
||||
bookings: initialBookings,
|
||||
isTransitioning: false,
|
||||
pendingSelection: null,
|
||||
capabilities: null,
|
||||
|
||||
// Actions
|
||||
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
|
||||
setSelectedBookingUid: (uid) => {
|
||||
set({ selectedBookingUid: uid });
|
||||
},
|
||||
setBookings: (bookings) => set({ bookings }),
|
||||
setCapabilities: (capabilities) => set({ capabilities }),
|
||||
clearSelection: () => set({ selectedBookingUid: null }),
|
||||
clearPendingSelection: () => set({ pendingSelection: null }),
|
||||
setIsTransitioning: (isTransitioning) => set({ isTransitioning }),
|
||||
|
||||
// Computed getters
|
||||
// Core getters
|
||||
getSelectedBooking: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
|
||||
},
|
||||
|
||||
getNextBookingUid: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
|
||||
|
||||
return state.bookings[currentIndex + 1].uid;
|
||||
getCurrentIndex: () => {
|
||||
const { bookings, selectedBookingUid } = get();
|
||||
if (!selectedBookingUid) return -1;
|
||||
return bookings.findIndex((b) => b.uid === selectedBookingUid);
|
||||
},
|
||||
|
||||
getPreviousBookingUid: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
if (currentIndex <= 0) return null;
|
||||
|
||||
return state.bookings[currentIndex - 1].uid;
|
||||
hasNextInArray: () => {
|
||||
const { bookings, getCurrentIndex } = get();
|
||||
const index = getCurrentIndex();
|
||||
return index >= 0 && index < bookings.length - 1;
|
||||
},
|
||||
|
||||
hasNext: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return false;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
|
||||
hasPreviousInArray: () => {
|
||||
const { getCurrentIndex } = get();
|
||||
return getCurrentIndex() > 0;
|
||||
},
|
||||
|
||||
hasPrevious: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingUid) return false;
|
||||
isFirstInArray: () => get().getCurrentIndex() === 0,
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
return currentIndex > 0;
|
||||
isLastInArray: () => {
|
||||
const { bookings, getCurrentIndex } = get();
|
||||
return getCurrentIndex() === bookings.length - 1;
|
||||
},
|
||||
|
||||
// Navigation methods
|
||||
navigateNext: async () => {
|
||||
const state = get();
|
||||
|
||||
// Try navigating within current array first
|
||||
if (state.hasNextInArray()) {
|
||||
const nextIndex = state.getCurrentIndex() + 1;
|
||||
set({ selectedBookingUid: state.bookings[nextIndex].uid });
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to navigate to next period
|
||||
if (!state.capabilities?.canNavigateToNextPeriod()) return;
|
||||
|
||||
// Set pending selection to "first" and mark as transitioning
|
||||
set({ isTransitioning: true, pendingSelection: "first" });
|
||||
// Trigger page/week change synchronously - the parent component will handle the data fetch
|
||||
state.capabilities.requestNextPeriod();
|
||||
},
|
||||
|
||||
navigatePrevious: async () => {
|
||||
const state = get();
|
||||
|
||||
// Try navigating within current array first
|
||||
if (state.hasPreviousInArray()) {
|
||||
const prevIndex = state.getCurrentIndex() - 1;
|
||||
set({ selectedBookingUid: state.bookings[prevIndex].uid });
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to navigate to previous period
|
||||
if (!state.capabilities?.canNavigateToPreviousPeriod()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set pending selection to "last" and mark as transitioning
|
||||
set({ isTransitioning: true, pendingSelection: "last" });
|
||||
// Trigger page/week change synchronously - the parent component will handle the data fetch
|
||||
state.capabilities.requestPreviousPeriod();
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -87,20 +172,34 @@ const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetS
|
||||
export function BookingDetailsSheetStoreProvider({
|
||||
children,
|
||||
bookings,
|
||||
capabilities,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bookings: BookingOutput[];
|
||||
capabilities?: NavigationCapabilities | null;
|
||||
}) {
|
||||
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
|
||||
const [store] = useState(() => createBookingDetailsSheetStore(bookings));
|
||||
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
|
||||
const previousBookingsRef = useRef<BookingOutput[]>(bookings);
|
||||
|
||||
// Update bookings in store
|
||||
useEffect(() => {
|
||||
const previousBookings = previousBookingsRef.current;
|
||||
const hasBookingsChanged = bookings !== previousBookings;
|
||||
|
||||
if (!hasBookingsChanged) return;
|
||||
|
||||
// Update bookings when they change
|
||||
React.useEffect(() => {
|
||||
store.getState().setBookings(bookings);
|
||||
previousBookingsRef.current = bookings;
|
||||
}, [bookings, store]);
|
||||
|
||||
// Update capabilities when they change
|
||||
useEffect(() => {
|
||||
store.getState().setCapabilities(capabilities ?? null);
|
||||
}, [capabilities, store]);
|
||||
|
||||
// Sync Store → URL
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
const storeUid = state.selectedBookingUid;
|
||||
if (storeUid !== selectedBookingUidFromUrl) {
|
||||
@@ -112,7 +211,7 @@ export function BookingDetailsSheetStoreProvider({
|
||||
}, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]);
|
||||
|
||||
// Sync URL → Store
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const currentStoreUid = store.getState().selectedBookingUid;
|
||||
if (currentStoreUid !== selectedBookingUidFromUrl) {
|
||||
store.getState().setSelectedBookingUid(selectedBookingUidFromUrl);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user