Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e2ac05e82f | ||
|
|
34ab02702e | ||
|
|
1da118a534 | ||
|
|
acf2f53daf | ||
|
|
c7bb28d64c | ||
|
|
8dc8d87ad0 | ||
|
|
da4fec9c27 | ||
|
|
0ecfd78e47 | ||
|
|
f007fe8407 | ||
|
|
e1324bd9c3 | ||
|
|
ca152d73f2 | ||
|
|
6f0c09d2be | ||
|
|
f6642c17f8 | ||
|
|
26b4385679 | ||
|
|
b220420630 | ||
|
|
7d5e9a4e0e | ||
|
|
5253f26c94 | ||
|
|
9b09544c62 | ||
|
|
44104e1ba4 | ||
|
|
191dcc45b5 | ||
|
|
5de8576473 | ||
|
|
6440c60c96 | ||
|
|
016ae6ea4b | ||
|
|
d3f7308b9f | ||
|
|
060176cd08 | ||
|
|
a306a77678 | ||
|
|
3986d61f40 | ||
|
|
41b71d845c | ||
|
|
e6bfdd9ebb | ||
|
|
f4223d1657 | ||
|
|
b0bf0aaec2 | ||
|
|
e4fde4dc16 | ||
|
|
6c21a12a88 | ||
|
|
34e1122667 | ||
|
|
6b62557a92 | ||
|
|
300b9c916d | ||
|
|
5ac374374c | ||
|
|
09a4247aa1 | ||
|
|
eb01e2f9d8 | ||
|
|
20efb566b2 | ||
|
|
d9ebf26e21 | ||
|
|
cb13874574 | ||
|
|
1fa02d957e | ||
|
|
529ca159f7 | ||
|
|
be12ac75b4 | ||
|
|
68cdfd42b1 | ||
|
|
3ebac0194e | ||
|
|
6bdf320640 | ||
|
|
cdfdce533e | ||
|
|
5e29a7fa2a | ||
|
|
97377c4554 | ||
|
|
acc82f808d | ||
|
|
3a59cd41cc | ||
|
|
6ae2ce8ed9 | ||
|
|
476460dad8 | ||
|
|
19cadd18e3 | ||
|
|
e20cc960f1 | ||
|
|
f350542d0d | ||
|
|
257a49ce88 | ||
|
|
75b93cebdb | ||
|
|
161ebdbbec | ||
|
|
978c5bdb95 | ||
|
|
895d039efc | ||
|
|
9f566bc8fc | ||
|
|
518e541177 | ||
|
|
c1be922ee1 | ||
|
|
967bba1e80 | ||
|
|
0fc26f782f | ||
|
|
fadd824d12 | ||
|
|
c1b7b6e83d | ||
|
|
e82a53a442 | ||
|
|
a6578fdcd2 | ||
|
|
4e68bcb021 | ||
|
|
1e673f1df4 | ||
|
|
b91f27dc80 | ||
|
|
becae27b45 | ||
|
|
584f5581fa | ||
|
|
e10c4c1cb5 | ||
|
|
c78552ce60 | ||
|
|
9631d0a64b | ||
|
|
1182460d5c |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Add defaultPhoneCountry prop to BookerPlatformWrapper with ISO 3166-1 alpha-2 type safety
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
This PR fixes the issue of connect atoms not working inside iframes. It also updates the atoms exports to include the `useAvailableSlots` hook.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: Booker accepts rrHostSubsetIds to specify round robin hosts
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": major
|
||||
---
|
||||
|
||||
feat: use tailwind v4 (breaking change)
|
||||
@@ -1,187 +0,0 @@
|
||||
language: en-US
|
||||
tone_instructions: ''
|
||||
early_access: false
|
||||
enable_free_tier: true
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: false
|
||||
high_level_summary_placeholder: '@coderabbitai summary'
|
||||
high_level_summary_in_walkthrough: false
|
||||
auto_title_placeholder: '@coderabbitai'
|
||||
auto_title_instructions: ''
|
||||
review_status: true
|
||||
commit_status: true
|
||||
fail_commit_status: false
|
||||
collapse_walkthrough: false
|
||||
changed_files_summary: false
|
||||
sequence_diagrams: false
|
||||
estimate_code_review_effort: false
|
||||
assess_linked_issues: true
|
||||
related_issues: false
|
||||
related_prs: true
|
||||
suggested_labels: false
|
||||
auto_apply_labels: false
|
||||
suggested_reviewers: false
|
||||
auto_assign_reviewers: false
|
||||
poem: false
|
||||
labeling_instructions: []
|
||||
path_filters:
|
||||
- '!**/node_modules/**'
|
||||
- '!**/dist/**'
|
||||
- '!**/build/**'
|
||||
- '!**/.next/**'
|
||||
path_instructions:
|
||||
- path: "**/*.{ts,tsx,js,jsx}"
|
||||
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
|
||||
abort_on_close: true
|
||||
disable_cache: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
auto_incremental_review: true
|
||||
ignore_title_keywords: []
|
||||
labels: []
|
||||
drafts: false
|
||||
base_branches: []
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: true
|
||||
unit_tests:
|
||||
enabled: true
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: warning
|
||||
threshold: 80
|
||||
title:
|
||||
mode: warning
|
||||
requirements: ''
|
||||
description:
|
||||
mode: warning
|
||||
issue_assessment:
|
||||
mode: warning
|
||||
tools:
|
||||
ast-grep:
|
||||
rule_dirs: []
|
||||
util_dirs: []
|
||||
essential_rules: true
|
||||
packages: []
|
||||
shellcheck:
|
||||
enabled: true
|
||||
ruff:
|
||||
enabled: true
|
||||
markdownlint:
|
||||
enabled: true
|
||||
github-checks:
|
||||
enabled: true
|
||||
timeout_ms: 180000
|
||||
languagetool:
|
||||
enabled: true
|
||||
enabled_rules: []
|
||||
disabled_rules: []
|
||||
enabled_categories: []
|
||||
disabled_categories: []
|
||||
enabled_only: false
|
||||
level: default
|
||||
biome:
|
||||
enabled: true
|
||||
hadolint:
|
||||
enabled: true
|
||||
swiftlint:
|
||||
enabled: true
|
||||
phpstan:
|
||||
enabled: true
|
||||
level: default
|
||||
phpmd:
|
||||
enabled: true
|
||||
phpcs:
|
||||
enabled: true
|
||||
golangci-lint:
|
||||
enabled: true
|
||||
yamllint:
|
||||
enabled: true
|
||||
gitleaks:
|
||||
enabled: true
|
||||
checkov:
|
||||
enabled: true
|
||||
detekt:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
flake8:
|
||||
enabled: true
|
||||
rubocop:
|
||||
enabled: true
|
||||
buf:
|
||||
enabled: true
|
||||
regal:
|
||||
enabled: true
|
||||
actionlint:
|
||||
enabled: true
|
||||
pmd:
|
||||
enabled: true
|
||||
cppcheck:
|
||||
enabled: true
|
||||
semgrep:
|
||||
enabled: true
|
||||
circleci:
|
||||
enabled: true
|
||||
clippy:
|
||||
enabled: true
|
||||
sqlfluff:
|
||||
enabled: true
|
||||
prismaLint:
|
||||
enabled: true
|
||||
pylint:
|
||||
enabled: true
|
||||
oxc:
|
||||
enabled: true
|
||||
shopifyThemeCheck:
|
||||
enabled: true
|
||||
luacheck:
|
||||
enabled: true
|
||||
brakeman:
|
||||
enabled: true
|
||||
dotenvLint:
|
||||
enabled: true
|
||||
htmlhint:
|
||||
enabled: true
|
||||
checkmake:
|
||||
enabled: true
|
||||
osvScanner:
|
||||
enabled: true
|
||||
chat:
|
||||
art: true
|
||||
auto_reply: true
|
||||
integrations:
|
||||
jira:
|
||||
usage: auto
|
||||
linear:
|
||||
usage: auto
|
||||
knowledge_base:
|
||||
opt_out: false
|
||||
web_search:
|
||||
enabled: true
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
filePatterns: []
|
||||
learnings:
|
||||
scope: auto
|
||||
issues:
|
||||
scope: auto
|
||||
jira:
|
||||
usage: auto
|
||||
project_keys: []
|
||||
linear:
|
||||
usage: auto
|
||||
team_keys: []
|
||||
pull_requests:
|
||||
scope: auto
|
||||
mcp:
|
||||
usage: auto
|
||||
disabled_servers: []
|
||||
code_generation:
|
||||
docstrings:
|
||||
language: en-US
|
||||
path_instructions: []
|
||||
unit_tests:
|
||||
path_instructions: []
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
|
||||
yarn app-store:build && git add packages/app-store/*.generated.*
|
||||
|
||||
@@ -1,30 +1,213 @@
|
||||
# Cal.com Development Guide for AI Agents
|
||||
|
||||
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
|
||||
You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioritize type safety, security, and small, reviewable diffs.
|
||||
|
||||
## Quick Reference
|
||||
## Do
|
||||
|
||||
### Essential Commands
|
||||
- Use `select` instead of `include` in Prisma queries for performance and security
|
||||
- Use `import type { X }` for TypeScript type imports
|
||||
- Use early returns to reduce nesting: `if (!booking) return null;`
|
||||
- Use `ErrorWithCode` for errors in non-tRPC files (services, repositories, utilities); use `TRPCError` only in tRPC routers
|
||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`
|
||||
- Create PRs in draft mode by default
|
||||
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
|
||||
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
|
||||
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
|
||||
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
||||
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
||||
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
|
||||
|
||||
- `yarn dev` - Start development server
|
||||
- `yarn build` - Build all packages
|
||||
- `yarn lint:fix` - Lint and fix code
|
||||
- `yarn type-check` - TypeScript checking
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
|
||||
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
|
||||
## Don't
|
||||
|
||||
## Tool Preferences
|
||||
- Never use `as any` - use proper type-safe solutions instead
|
||||
- Never expose `credential.key` field in API responses or queries
|
||||
- Never commit secrets or API keys
|
||||
- Never modify `*.generated.ts` files directly - they're created by app-store-cli
|
||||
- Never put business logic in repositories - that belongs in Services
|
||||
- Never use barrel imports from index.ts files
|
||||
- Never skip running type checks before pushing
|
||||
- Never create large PRs (>500 lines or >10 files) - split them instead
|
||||
|
||||
### Search Tools Priority
|
||||
## Commands
|
||||
|
||||
Use tools in this order of preference:
|
||||
### File-scoped (preferred for speed)
|
||||
|
||||
1. **ast-grep** - For AST-based code searches (if available)
|
||||
2. **rg (ripgrep)** - For fast text searches
|
||||
3. **grep** - As fallback for text searches
|
||||
```bash
|
||||
# Type check - always run on changed files
|
||||
yarn type-check:ci --force
|
||||
|
||||
## 📚 Detailed Documentation
|
||||
# Lint single file
|
||||
yarn eslint --fix path/to/file.tsx
|
||||
|
||||
- **[.agents/README.md](.agents/README.md)** - Complete development guide
|
||||
- **[.agents/commands.md](.agents/commands.md)** - All build, test & dev commands
|
||||
- **[.agents/knowledge-base.md](.agents/knowledge-base.md)** - Knowledge base & best practices
|
||||
# Format single file
|
||||
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)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn dev # Start dev server
|
||||
yarn dx # Dev with database setup
|
||||
|
||||
# Build & check
|
||||
yarn build # Build all packages
|
||||
yarn lint:fix # Lint and fix all
|
||||
yarn type-check # Type check all
|
||||
|
||||
# Tests (use TZ=UTC for consistency)
|
||||
TZ=UTC yarn test # All unit tests
|
||||
yarn e2e # All E2E tests
|
||||
|
||||
# Database
|
||||
yarn prisma generate # Regenerate types after schema changes
|
||||
yarn workspace @calcom/prisma db-migrate # Run migrations
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Always do
|
||||
- Run type check on changed files before committing
|
||||
- Run relevant tests before pushing
|
||||
- Use `select` in Prisma queries
|
||||
- Follow conventional commits for PR titles
|
||||
|
||||
### Ask first
|
||||
- Adding new dependencies
|
||||
- Schema changes to `packages/prisma/schema.prisma`
|
||||
- Changes affecting multiple packages
|
||||
- Deleting files
|
||||
- Running full build or E2E suites
|
||||
|
||||
### Never do
|
||||
- Commit secrets, API keys, or `.env` files
|
||||
- Expose `credential.key` in any query
|
||||
- Use `as any` type casting
|
||||
- Force push or rebase shared branches
|
||||
- Modify generated files directly
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ # Main Next.js application
|
||||
packages/prisma/ # Database schema (schema.prisma) and migrations
|
||||
packages/trpc/ # tRPC API layer (routers in server/routers/)
|
||||
packages/ui/ # Shared UI components
|
||||
packages/features/ # Feature-specific code
|
||||
packages/app-store/ # Third-party integrations
|
||||
packages/lib/ # Shared utilities
|
||||
```
|
||||
|
||||
### Key files
|
||||
- Routes: `apps/web/app/` (App Router)
|
||||
- Database schema: `packages/prisma/schema.prisma`
|
||||
- tRPC routers: `packages/trpc/server/routers/`
|
||||
- Translations: `apps/web/public/static/locales/en/common.json`
|
||||
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 13+ (App Router in some areas)
|
||||
- **Language**: TypeScript (strict)
|
||||
- **Database**: PostgreSQL with Prisma ORM
|
||||
- **API**: tRPC for type-safe APIs
|
||||
- **Auth**: NextAuth.js
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Testing**: Vitest (unit), Playwright (E2E)
|
||||
- **i18n**: next-i18next
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Good error handling
|
||||
|
||||
```typescript
|
||||
// Good - Descriptive error with context
|
||||
throw new Error(`Unable to create booking: User ${userId} has no available time slots for ${date}`);
|
||||
|
||||
// Bad - Generic error
|
||||
throw new Error("Booking failed");
|
||||
```
|
||||
|
||||
For which error class to use (`ErrorWithCode` vs `TRPCError`) and concrete examples, see [Error Types in knowledge-base.md](agents/knowledge-base.md#error-types).
|
||||
|
||||
### Good Prisma query
|
||||
|
||||
```typescript
|
||||
// Good - Use select for performance and security
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bad - Include fetches all fields including sensitive ones
|
||||
const booking = await prisma.booking.findFirst({
|
||||
include: { user: true }
|
||||
});
|
||||
```
|
||||
|
||||
### Good imports
|
||||
|
||||
```typescript
|
||||
// Good - Type imports and direct paths
|
||||
import type { User } from "@prisma/client";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
// Bad - Regular import for types, barrel imports
|
||||
import { User } from "@prisma/client";
|
||||
import { Button } from "@calcom/ui";
|
||||
```
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- [ ] Title follows conventional commits: `feat(scope): description`
|
||||
- [ ] Type check passes: `yarn type-check:ci --force`
|
||||
- [ ] Lint passes: `yarn lint:fix`
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] Diff is small and focused (<500 lines, <10 files)
|
||||
- [ ] No secrets or API keys committed
|
||||
- [ ] UI strings added to translation files
|
||||
- [ ] Created as draft PR
|
||||
|
||||
## When Stuck
|
||||
|
||||
- Ask a clarifying question before making large speculative changes
|
||||
- Propose a short plan for complex tasks
|
||||
- Open a draft PR with notes if unsure about approach
|
||||
- Fix type errors before test failures - they're often the root cause
|
||||
- Run `yarn prisma generate` if you see missing enum/type errors
|
||||
|
||||
## Extended Documentation
|
||||
|
||||
For detailed information, see the `agents/` directory:
|
||||
|
||||
- **[agents/README.md](agents/README.md)** - Architecture overview and patterns
|
||||
- **[agents/commands.md](agents/commands.md)** - Complete command reference
|
||||
- **[agents/knowledge-base.md](agents/knowledge-base.md)** - Domain knowledge and best practices
|
||||
- **[agents/coding-standards.md](agents/coding-standards.md)** - Coding standards with examples
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -836,12 +836,12 @@ following
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
2. From within the home of the Developer account page, go to "Manage apps".
|
||||
3. Click "Create app" button top right.
|
||||
3. Click "Create legacy app" button top right and select public app.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scopes called `crm.objects.contacts` and `crm.lists`.
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ import { User } from "@prisma/client";
|
||||
|
||||
- Use React children and context instead of passing props through multiple components
|
||||
|
||||
### ORM and Types
|
||||
|
||||
- Never import `@calcom/prisma/client` in features, services, UI, or handlers.
|
||||
- Use repository DTOs or domain types instead.
|
||||
- See "Repository + DTO Pattern and Method Conventions" in the knowledge base for details and examples.
|
||||
|
||||
### Security Rules
|
||||
|
||||
```typescript
|
||||
@@ -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)
|
||||
@@ -225,16 +225,38 @@ throw new Error("Booking failed");
|
||||
|
||||
### Error Types
|
||||
|
||||
Use `ErrorWithCode` for files that are not directly coupled to tRPC. The tRPC package has a middleware called `errorConversionMiddleware` that automatically converts `ErrorWithCode` instances into `TRPCError` instances.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Use proper error classes
|
||||
// ✅ Good - Use ErrorWithCode in non-tRPC files (services, repositories, utilities)
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
|
||||
// Option 1: Using constructor with ErrorCode enum
|
||||
throw new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found");
|
||||
|
||||
// Option 2: Using the Factory pattern for common HTTP errors
|
||||
throw ErrorWithCode.Factory.Forbidden("You don't have permission to view this");
|
||||
throw ErrorWithCode.Factory.NotFound("Resource not found");
|
||||
throw ErrorWithCode.Factory.BadRequest("Invalid input");
|
||||
|
||||
// ✅ Good - Use TRPCError only in tRPC routers/procedures
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid booking time slot",
|
||||
});
|
||||
|
||||
// ❌ Bad - Using TRPCError in non-tRPC files
|
||||
import { TRPCError } from "@trpc/server";
|
||||
// Don't use TRPCError in services, repositories, or utility files
|
||||
```
|
||||
|
||||
### packages/features Import Restrictions
|
||||
|
||||
Files in `packages/features/**` should NOT import from `trpc`. This keeps the features package decoupled from the tRPC layer, making the code more reusable and testable. Use `ErrorWithCode` for error handling in these files, and let the tRPC middleware handle the conversion.
|
||||
|
||||
## Basic Performance Guidelines
|
||||
|
||||
- Aim for O(n) or O(n log n) complexity, avoid O(n²)
|
||||
@@ -306,9 +328,54 @@ import { Button } from "@calcom/ui/components/button";
|
||||
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
||||
|
||||
## Repository Method Conventions
|
||||
## Repository + DTO Pattern and Method Conventions
|
||||
|
||||
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
|
||||
We use a Repository + DTO pattern to isolate Prisma and the database from business logic. Repositories are the only layer that talks to Prisma and database models. All services, tRPC handlers, API controllers, workflows, and UI should depend on DTOs or domain types, not Prisma types.
|
||||
|
||||
DTOs (Data Transfer Objects) are simple TypeScript types or interfaces that represent our domain data in an ORM-agnostic way and are returned from repositories.
|
||||
|
||||
### Why this pattern?
|
||||
|
||||
- **Prevent type leaks**: Prisma type changes stay inside repositories instead of breaking services, handlers, and UI across dozens of files.
|
||||
- **Enable safe refactors**: Business logic works against stable DTOs, so you can change storage details without touching higher layers.
|
||||
- **Keep ORM swappable**: If we ever change ORM, only repository implementations change; services and handlers stay the same.
|
||||
|
||||
### What not to do: tight Prisma coupling in business logic
|
||||
|
||||
Do not import Prisma types in business logic. Prisma types belong only in the data access layer.
|
||||
|
||||
```typescript
|
||||
// ❌ Bad – business logic depends on Prisma
|
||||
import type { Webhook } from "@calcom/prisma/client";
|
||||
export function sendPayload(webhook: Webhook) { /* ... */ }
|
||||
|
||||
// ✅ Good – business logic depends on DTO + repository
|
||||
export interface WebhookDTO { id: string; url: string; }
|
||||
export interface IWebhookRepository { findById(id: string): Promise<WebhookDTO | null>; }
|
||||
export async function sendPayload(repo: IWebhookRepository, id: string) {
|
||||
const webhook = await repo.findById(id);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### DTOs in repositories
|
||||
|
||||
Repositories should expose DTOs from their public methods and keep Prisma types internal. Inside the repository, use `Prisma.*GetPayload` and map rows to DTOs. The public methods should only return DTOs, never Prisma models.
|
||||
|
||||
```typescript
|
||||
// DTO – ORM-agnostic type exported from the repository module
|
||||
export interface BookingDTO {
|
||||
id: string;
|
||||
userId: number;
|
||||
startTime: Date;
|
||||
status: "PENDING" | "CONFIRMED" | "CANCELLED";
|
||||
}
|
||||
```
|
||||
|
||||
### Prisma boundaries
|
||||
|
||||
- **Allowed**: `packages/prisma`, repository implementations (`packages/features/**/repositories/*Repository.ts`), and low-level data access infrastructure.
|
||||
- **Not allowed**: `packages/features/**` business logic (non-repository), `packages/trpc/**` handlers, `apps/web/**`, `apps/api/v2/**` services/controllers, and workflow/webhook/service layers.
|
||||
|
||||
### Method Naming Rules
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^15.4.8",
|
||||
"next": "15.4.10",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -125,7 +125,7 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -270,6 +270,7 @@ export class BookingsController_2024_04_15 {
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
userUuid: bookingRequest.userUuid,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
@@ -300,7 +301,7 @@ export class BookingsController_2024_04_15 {
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async markNoShow(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: MarkNoShowInput_2024_04_15,
|
||||
@Param("bookingUid") bookingUid: string
|
||||
): Promise<MarkNoShowOutput_2024_04_15> {
|
||||
@@ -309,7 +310,8 @@ export class BookingsController_2024_04_15 {
|
||||
bookingUid: bookingUid,
|
||||
attendees: body.attendees,
|
||||
noShowHost: body.noShowHost,
|
||||
userId,
|
||||
userId: user.id,
|
||||
userUuid: user.uuid,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||
@@ -378,7 +380,7 @@ export class BookingsController_2024_04_15 {
|
||||
): Promise<ApiResponse<InstantBookingCreateResult>> {
|
||||
const oAuthClientId =
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.userId = (await this.getOwner(req))?.id ?? -1;
|
||||
try {
|
||||
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const instantMeeting = await this.instantBookingCreateService.createBooking({
|
||||
@@ -405,30 +407,45 @@ export class BookingsController_2024_04_15 {
|
||||
throw new InternalServerErrorException("Could not create instant booking.");
|
||||
}
|
||||
|
||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
private async getOwner(req: Request): Promise<{ id: number; uuid: string } | null> {
|
||||
try {
|
||||
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (bearerToken) {
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
// Access Token
|
||||
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
return ownerId;
|
||||
}
|
||||
if (!bearerToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ownerId: number | null = null;
|
||||
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
ownerId = keyData?.userId ?? null;
|
||||
} else {
|
||||
// Access Token
|
||||
ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
}
|
||||
|
||||
if (!ownerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findById(ownerId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { id: user.id, uuid: user.uuid };
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnerIdRescheduledBooking(
|
||||
private async getOwnerRescheduledBooking(
|
||||
request: Request,
|
||||
platformClientId?: string
|
||||
): Promise<number | undefined> {
|
||||
): Promise<{ id: number; uuid: string } | null> {
|
||||
if (
|
||||
platformClientId &&
|
||||
request.body.rescheduledBy &&
|
||||
@@ -440,13 +457,15 @@ export class BookingsController_2024_04_15 {
|
||||
);
|
||||
}
|
||||
|
||||
if (request.body.rescheduledBy) {
|
||||
if (request.body.rescheduledBy !== request.body.responses.email) {
|
||||
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
|
||||
if (request.body.rescheduledBy && request.body.rescheduledBy !== request.body.responses.email) {
|
||||
const user = await this.usersRepository.findByEmail(request.body.rescheduledBy);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return { id: user.id, uuid: user.uuid };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
|
||||
@@ -498,7 +517,8 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
const owner = await this.getOwner(req);
|
||||
const userId = owner?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException(
|
||||
@@ -561,12 +581,16 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string,
|
||||
isEmbed?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
|
||||
const requestId = req.get("X-Request-Id");
|
||||
const clone = { ...req };
|
||||
const userId = clone.body.rescheduleUid
|
||||
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwnerId(req);
|
||||
const owner = clone.body.rescheduleUid
|
||||
? await this.getOwnerRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwner(req);
|
||||
|
||||
const userId = owner?.id;
|
||||
const userUuid = owner?.uuid;
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
@@ -577,7 +601,7 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId,
|
||||
...oAuthParams,
|
||||
});
|
||||
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
|
||||
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
|
||||
clone.body = {
|
||||
...clone.body,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
@@ -586,7 +610,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (oAuthClientId) {
|
||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||
}
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
async setPlatformAttendeesEmails(
|
||||
@@ -612,9 +636,12 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string,
|
||||
isEmbed?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
|
||||
const clone = { ...req };
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const owner = await this.getOwner(req);
|
||||
const userId = owner?.id ?? -1;
|
||||
const userUuid = owner?.uuid;
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
@@ -628,6 +655,7 @@ export class BookingsController_2024_04_15 {
|
||||
});
|
||||
Object.assign(clone, {
|
||||
userId,
|
||||
userUuid,
|
||||
...oAuthParams,
|
||||
platformBookingLocation,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
@@ -636,7 +664,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (oAuthClientId) {
|
||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||
}
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ValidateNested,
|
||||
isEmail,
|
||||
Validate,
|
||||
IsInt,
|
||||
} from "class-validator";
|
||||
import { ValidationOptions, registerDecorator } from "class-validator";
|
||||
|
||||
@@ -26,7 +27,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: any) {
|
||||
validate(value: string | Record<string, string>): boolean {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
@@ -226,4 +227,16 @@ export class CreateBookingInput_2024_04_15 {
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
crmOwnerRecordType?: string;
|
||||
|
||||
/* @ApiPropertyOptional({
|
||||
type: [Number],
|
||||
description:
|
||||
"For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.",
|
||||
example: [1, 2, 3],
|
||||
}) */
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
rrHostSubsetIds?: number[];
|
||||
}
|
||||
|
||||
@@ -397,18 +397,18 @@ export class BookingsController_2024_08_13 {
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser("id") ownerId: number
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, user.id, body, user.uuid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reassign")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -64,12 +64,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
let team1EventTypeId: number;
|
||||
let team2EventTypeId: number;
|
||||
let team2RREventTypeId: number;
|
||||
let phoneOnlyEventTypeId: number;
|
||||
let collectiveEventWithoutHostsId: number;
|
||||
let roundRobinEventWithoutHostsId: number;
|
||||
|
||||
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
const team2RREventTypeSlug = `team-bookings-rr-event-type-${randomString()}`;
|
||||
const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
|
||||
let phoneBasedBooking: BookingOutput_2024_08_13;
|
||||
@@ -339,6 +341,50 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
team2EventTypeId = team2EventType.id;
|
||||
|
||||
const team2RREventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team2.id },
|
||||
},
|
||||
title: `team-bookings-2024-08-13-event-type-rr-${randomString()}`,
|
||||
slug: team2RREventTypeSlug,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
rrHostSubsetEnabled: true,
|
||||
});
|
||||
|
||||
team2RREventTypeId = team2RREventType.id;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2RREventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2RREventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
@@ -603,6 +649,163 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser2 as host ", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(),
|
||||
eventTypeId: team2RREventTypeId,
|
||||
attendee: {
|
||||
name: "bob2",
|
||||
email: "bob2@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
rrHostSubsetIds: [teamUser2.id],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser2.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team2RREventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser as host ", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(),
|
||||
eventTypeId: team2RREventTypeId,
|
||||
attendee: {
|
||||
name: "bob",
|
||||
email: "bob@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
rrHostSubsetIds: [teamUser.id],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team2RREventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser and teamUser2 as host ", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: team2RREventTypeId,
|
||||
attendee: {
|
||||
name: "bob",
|
||||
email: "bob@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
rrHostSubsetIds: [teamUser.id, teamUser2.id],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team2RREventTypeId);
|
||||
expect(data.attendees.length).toEqual(2);
|
||||
expect(data.attendees.find((a) => a.email === body.attendee.email)).toBeDefined();
|
||||
expect(data.attendees.find((a) => a.email === teamUser2.email)).toBeDefined();
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get team bookings", () => {
|
||||
@@ -639,7 +842,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
|
||||
});
|
||||
});
|
||||
@@ -684,7 +887,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data.length).toEqual(5);
|
||||
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
|
||||
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
|
||||
});
|
||||
@@ -845,6 +1048,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await userRepositoryFixture.deleteByEmail(teamUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -919,34 +919,40 @@ export class BookingsService_2024_08_13 {
|
||||
return await this.getBooking(recurringBookingUid, authUser);
|
||||
}
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
async markAbsent(
|
||||
bookingUid: string,
|
||||
bookingOwnerId: number,
|
||||
body: MarkAbsentBookingInput_2024_08_13,
|
||||
userUuid?: string
|
||||
) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
platformClientParams,
|
||||
});
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
userUuid,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
location,
|
||||
},
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
rrHostSubsetIds: inputBooking.rrHostSubsetIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -484,6 +485,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
rrHostSubsetIds: inputBooking.rrHostSubsetIds,
|
||||
});
|
||||
|
||||
switch (timeBetween) {
|
||||
|
||||
+2
@@ -1486,6 +1486,7 @@ describe("Event types Endpoints", () => {
|
||||
locations: [],
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: team.id } },
|
||||
rrHostSubsetEnabled: true,
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -1498,6 +1499,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(teamEventType.id);
|
||||
expect(responseBody.data.teamId).toEqual(team.id);
|
||||
expect(responseBody.data.rrHostSubsetEnabled).toEqual(true);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-4
@@ -27,6 +27,7 @@ import {
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { slugify } 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 = slugify(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);
|
||||
@@ -229,14 +235,21 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
...metadataTransformed,
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
...(bookerLayouts !== undefined
|
||||
? { bookerLayouts: this.transformInputBookerLayouts(bookerLayouts) }
|
||||
: {}),
|
||||
...(confirmationPolicy !== undefined
|
||||
? {
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(lengthInMinutesOptions !== undefined ? { multipleDuration: lengthInMinutesOptions } : {}),
|
||||
};
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
...(slug ? { slug: slugify(slug) } : {}),
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: effectiveBookingFields
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -8,7 +9,7 @@ import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15],
|
||||
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15, PrismaScheduleRepository],
|
||||
controllers: [SchedulesController_2024_04_15],
|
||||
exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15],
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
|
||||
import type { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Schedule } from "@calcom/prisma/client";
|
||||
@@ -16,7 +16,8 @@ export class SchedulesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly prismaScheduleRepository: PrismaScheduleRepository
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
@@ -53,8 +54,7 @@ export class SchedulesService_2024_04_15 {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return await ScheduleRepository.findDetailedScheduleById({
|
||||
return await this.prismaScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: user.defaultScheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
@@ -70,7 +70,7 @@ export class SchedulesService_2024_04_15 {
|
||||
throw new NotFoundException(`User with ID=${userId} does not exist.`);
|
||||
}
|
||||
|
||||
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
|
||||
const existingSchedule = await this.prismaScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: scheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
@@ -88,7 +88,11 @@ export class SchedulesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
|
||||
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
|
||||
return this.prismaScheduleRepository.findManyDetailedScheduleByUserId({
|
||||
userId,
|
||||
timeZone,
|
||||
defaultScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserSchedule(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
|
||||
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
||||
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
@@ -16,6 +17,7 @@ import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booki
|
||||
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
@@ -35,6 +37,7 @@ import { Module, Scope } from "@nestjs/common";
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
BookingAuditProducerService,
|
||||
BookingEventHandlerService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
@@ -45,6 +48,7 @@ import { Module, Scope } from "@nestjs/common";
|
||||
BookingEmailAndSmsSyncTaskerService,
|
||||
BookingEmailAndSmsTriggerTaskerService,
|
||||
BookingEmailAndSmsTasker,
|
||||
TaskerService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
|
||||
@@ -0,0 +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";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
|
||||
constructor(taskerService: TaskerService, bridgeLogger: Logger) {
|
||||
super({
|
||||
tasker: taskerService.getTasker(),
|
||||
log: bridgeLogger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@c
|
||||
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
|
||||
import { BookingAuditProducerService } from "./booking-audit-producer.service";
|
||||
import { HashedLinkService } from "./hashed-link.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
|
||||
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
|
||||
constructor(
|
||||
hashedLinkService: HashedLinkService,
|
||||
bridgeLogger: Logger,
|
||||
bookingAuditProducerService: BookingAuditProducerService
|
||||
) {
|
||||
super({
|
||||
log: bridgeLogger,
|
||||
hashedLinkService,
|
||||
bookingAuditProducerService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TaskerService {
|
||||
private readonly tasker: Tasker;
|
||||
|
||||
constructor() {
|
||||
this.tasker = getTasker();
|
||||
}
|
||||
|
||||
getTasker(): Tasker {
|
||||
return this.tasker;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ export class SchedulesAtomsService {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return await ScheduleRepository.findDetailedScheduleById({
|
||||
const scheduleRepo = new ScheduleRepository(this.dbWrite.prisma as unknown as PrismaClient);
|
||||
return await scheduleRepo.findDetailedScheduleById({
|
||||
scheduleId: scheduleId ?? user.defaultScheduleId,
|
||||
isManagedEventType,
|
||||
userId,
|
||||
|
||||
+78
@@ -389,6 +389,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.rrHostSubsetEnabled).toEqual(false);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
expect(data.emailSettings).toEqual(body.emailSettings);
|
||||
collectiveEventType = responseBody.data;
|
||||
@@ -537,6 +538,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const data = responseBody.data;
|
||||
expect(data.title).toEqual(collectiveEventType.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.rrHostSubsetEnabled).toEqual(false);
|
||||
evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
|
||||
evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
|
||||
|
||||
@@ -1142,6 +1144,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
rrHostSubsetEnabled: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
lightThemeHex: "#fafafa",
|
||||
@@ -1175,6 +1178,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.rrHostSubsetEnabled).toEqual(true);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
collectiveEventType = responseBody.data;
|
||||
});
|
||||
@@ -1294,6 +1298,80 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
|
||||
});
|
||||
|
||||
it("should preserve metadata fields when doing partial update", async () => {
|
||||
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation with metadata",
|
||||
slug: `organizations-event-types-metadata-${randomString()}`,
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 30,
|
||||
lengthInMinutesOptions: [15, 30, 60],
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
},
|
||||
],
|
||||
bookerLayouts: {
|
||||
enabledLayouts: [
|
||||
BookerLayoutsInputEnum_2024_06_14.column,
|
||||
BookerLayoutsInputEnum_2024_06_14.month,
|
||||
BookerLayoutsInputEnum_2024_06_14.week,
|
||||
],
|
||||
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
|
||||
},
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
noticeThreshold: {
|
||||
count: 60,
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
},
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType: TeamEventTypeOutput_2024_06_14 = createResponse.body.data;
|
||||
|
||||
// Verify all metadata fields are set correctly on creation
|
||||
expect(createdEventType.lengthInMinutesOptions).toBeDefined();
|
||||
expect(createdEventType.lengthInMinutesOptions).toEqual([15, 30, 60]);
|
||||
expect(createdEventType.bookerLayouts).toBeDefined();
|
||||
expect(createdEventType.bookerLayouts).toEqual(createBody.bookerLayouts);
|
||||
expect(createdEventType.confirmationPolicy).toBeDefined();
|
||||
expect(createdEventType.confirmationPolicy).toEqual(createBody.confirmationPolicy);
|
||||
|
||||
// Now do a partial update that only changes a different field (not metadata fields)
|
||||
const updateBody: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
bookingRequiresAuthentication: false,
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${createdEventType.id}`)
|
||||
.send(updateBody)
|
||||
.expect(200);
|
||||
|
||||
const updatedEventType: TeamEventTypeOutput_2024_06_14 = updateResponse.body.data;
|
||||
|
||||
// Verify that all metadata fields are preserved and not reset to undefined
|
||||
expect(updatedEventType.lengthInMinutesOptions).toBeDefined();
|
||||
expect(updatedEventType.lengthInMinutesOptions).toEqual([15, 30, 60]);
|
||||
expect(updatedEventType.bookerLayouts).toBeDefined();
|
||||
expect(updatedEventType.bookerLayouts).toEqual(createBody.bookerLayouts);
|
||||
expect(updatedEventType.confirmationPolicy).toBeDefined();
|
||||
expect(updatedEventType.confirmationPolicy).toEqual(createBody.confirmationPolicy);
|
||||
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
|
||||
@@ -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 { slugify } 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: slugify(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: slugify(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,
|
||||
|
||||
@@ -88,6 +88,7 @@ type Input = Pick<
|
||||
| "rescheduleWithSameRoundRobinHost"
|
||||
| "maxActiveBookingPerBookerOfferReschedule"
|
||||
| "maxActiveBookingsPerBooker"
|
||||
| "rrHostSubsetEnabled"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -103,8 +104,14 @@ export class OutputOrganizationsEventTypesService {
|
||||
|
||||
const emailSettings = this.transformEmailSettings(metadata);
|
||||
|
||||
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
|
||||
databaseEventType;
|
||||
const {
|
||||
teamId,
|
||||
userId,
|
||||
parentId,
|
||||
assignAllTeamMembers,
|
||||
rescheduleWithSameRoundRobinHost,
|
||||
rrHostSubsetEnabled,
|
||||
} = databaseEventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
|
||||
0,
|
||||
@@ -139,6 +146,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
theme: databaseEventType?.team?.theme,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost,
|
||||
rrHostSubsetEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+112
-2
@@ -53,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
|
||||
let teammateApiKeyString: string;
|
||||
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
|
||||
const teammateEmailThree = `slots-2024-09-04-user-3-team-slots-${randomString()}`;
|
||||
let teammateTwoApiKeyString: string;
|
||||
|
||||
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
|
||||
@@ -63,6 +64,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
let team: Team;
|
||||
let teammateOne: User;
|
||||
let teammateTwo: User;
|
||||
let teammateThree: User;
|
||||
let collectiveEventTypeId: number;
|
||||
let collectiveEventTypeSlug: string;
|
||||
let collectiveEventTypeWithoutHostsId: number;
|
||||
@@ -114,6 +116,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
username: teammateEmailTwo,
|
||||
});
|
||||
|
||||
teammateThree = await userRepositoryFixture.create({
|
||||
email: teammateEmailThree,
|
||||
name: teammateEmailThree,
|
||||
username: teammateEmailThree,
|
||||
});
|
||||
|
||||
outsider = await userRepositoryFixture.create({
|
||||
email: outsiderEmail,
|
||||
name: outsiderEmail,
|
||||
@@ -155,6 +163,13 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammateThree.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
@@ -243,7 +258,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }, { id: teammateThree.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
@@ -255,8 +270,13 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
userId: teammateTwo.id,
|
||||
isFixed: false,
|
||||
},
|
||||
{
|
||||
userId: teammateThree.id,
|
||||
isFixed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
rrHostSubsetEnabled: true,
|
||||
});
|
||||
|
||||
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
|
||||
@@ -274,7 +294,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }, { id: teammateThree.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
@@ -286,8 +306,13 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
userId: teammateTwo.id,
|
||||
isFixed: false,
|
||||
},
|
||||
{
|
||||
userId: teammateThree.id,
|
||||
isFixed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
rrHostSubsetEnabled: true,
|
||||
});
|
||||
|
||||
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
|
||||
@@ -301,6 +326,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
await schedulesService.createUserSchedule(teammateOne.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teammateTwo.id, userSchedule);
|
||||
|
||||
await schedulesService.createUserSchedule(teammateThree.id, {
|
||||
...userSchedule,
|
||||
availability: [
|
||||
{
|
||||
days: ["Monday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -360,6 +396,79 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event without fixed hosts slots in UTC with subsetIds for teammateThree who has a smaller schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${roundRobinEventTypeWithoutFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateThree.id}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event without fixed hosts slots in UTC with subsetIds for teammateOne", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${roundRobinEventTypeWithoutFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event with and without fixed hosts slots in UTC with subsetIds for teammateOne(fixed) and teammateThree(not fixed) ", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${roundRobinEventTypeWithFixedAndNonFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}&rrHostSubsetIds[]=${teammateThree.id}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event with and without fixed hosts slots in UTC with subsetIds for teammateOne(fixed) and teammateTwo(not fixed) ", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${roundRobinEventTypeWithFixedAndNonFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}&rrHostSubsetIds[]=${teammateTwo.id}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able reserve a team event type slot with custom duration if no auth is provided", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
@@ -748,6 +857,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teammateOne.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateThree.email);
|
||||
await userRepositoryFixture.deleteByEmail(outsiderEmail);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await bookingsRepositoryFixture.deleteById(collectiveBookingId);
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export type InternalGetSlotsQuery = {
|
||||
timeZone: string | undefined;
|
||||
orgSlug: string | null | undefined;
|
||||
rescheduleUid: string | null;
|
||||
rrHostSubsetIds?: number[];
|
||||
};
|
||||
|
||||
export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & {
|
||||
@@ -79,6 +80,7 @@ export class SlotsInputService_2024_09_04 {
|
||||
timeZone,
|
||||
orgSlug,
|
||||
rescheduleUid,
|
||||
rrHostSubsetIds: query.rrHostSubsetIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -13,7 +13,7 @@ import Page from "~/org/[orgSlug]/instant-meeting/team/[slug]/[type]/instant-mee
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const { isBrandingHidden, eventData } = await getData(context);
|
||||
const { isBrandingHidden, eventData, teamIsPrivate } = await getData(context);
|
||||
|
||||
const profileName = eventData?.profile.name ?? "";
|
||||
const profileImage = eventData?.profile.image;
|
||||
@@ -22,12 +22,15 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
|
||||
const meeting = {
|
||||
title,
|
||||
profile: { name: profileName, image: profileImage },
|
||||
users: [
|
||||
...(eventData?.users || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
// Hide team member names in preview if team is private
|
||||
users: teamIsPrivate
|
||||
? []
|
||||
: [
|
||||
...(eventData?.users || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
const decodedParams = decodeParams(await params);
|
||||
const metadata = await generateMeetingMetadata(
|
||||
|
||||
@@ -91,15 +91,20 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const profileName = enrichedEventType.profile.name ?? "";
|
||||
const profileImage = enrichedEventType.profile.image;
|
||||
|
||||
const teamIsPrivate = teamData.isPrivate;
|
||||
|
||||
const meeting = {
|
||||
title,
|
||||
profile: { name: profileName, image: profileImage },
|
||||
users: [
|
||||
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
// Hide team member names in preview if team is private
|
||||
users: teamIsPrivate
|
||||
? []
|
||||
: [
|
||||
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
const { hideBranding, isSEOIndexable } = _getTeamMetadataForBooking(teamData, enrichedEventType.id);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Added as a separate route for now to ease the testing of the audit logs feature
|
||||
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
|
||||
// TOOD: Move it to the booking page side bar later
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import BookingLogsView from "~/booking/logs/views/booking-logs-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("booking_history"),
|
||||
(t) => t("booking_history_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/booking/${(await params).uid}/logs`
|
||||
);
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const resolvedParams = await params;
|
||||
const bookingUid = resolvedParams.uid;
|
||||
|
||||
if (!bookingUid || typeof bookingUid !== "string") {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
|
||||
<BookingLogsView bookingUid={bookingUid} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
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 "~/bookings/lib/viewParser";
|
||||
|
||||
export function ViewToggleButton() {
|
||||
const { t } = useLocale();
|
||||
const [view, setView] = useQueryState(
|
||||
"view",
|
||||
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
|
||||
);
|
||||
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// Force list view on mobile
|
||||
if (isMobile && view === "calendar") {
|
||||
// Clear dateRange filter when forcing calendar to list view, same as manual toggle
|
||||
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
|
||||
setView("list");
|
||||
}
|
||||
}, [isMobile, view, setView, setActiveFilters]);
|
||||
|
||||
return (
|
||||
<div className="hidden sm:block">
|
||||
<ToggleGroup
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const newView = value as BookingView;
|
||||
|
||||
// When switching from calendar to list view, remove the dateRange filter
|
||||
if (view === "calendar" && newView === "list") {
|
||||
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
|
||||
}
|
||||
|
||||
setView(newView);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "list",
|
||||
label: "",
|
||||
tooltip: t("list_view"),
|
||||
iconLeft: <Icon name="menu" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "calendar",
|
||||
label: "",
|
||||
tooltip: t("calendar_view"),
|
||||
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
@@ -16,8 +16,6 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import BookingsList from "~/bookings/views/bookings-view";
|
||||
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
const querySchema = z.object({
|
||||
status: z.enum(validStatuses),
|
||||
});
|
||||
@@ -36,7 +34,6 @@ 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;
|
||||
@@ -56,18 +53,13 @@ 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
|
||||
heading={t("bookings")}
|
||||
subtitle={t("bookings_description")}
|
||||
CTA={bookingsV3Enabled ? <ViewToggleButton /> : null}>
|
||||
<ShellMainAppDir>
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const ServerPage = async (props: { searchParams: Promise<{ fromTeamOnboarding?: string }> }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -28,8 +28,10 @@ const ServerPage = async () => {
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
const userName = session.user.name || "";
|
||||
const searchParams = await props.searchParams;
|
||||
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
|
||||
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
+171
-51
@@ -37,13 +37,41 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
href: "/settings/my-account",
|
||||
icon: "user",
|
||||
children: [
|
||||
{ name: "profile", href: "/settings/my-account/profile", trackingMetadata: { section: "my_account", page: "profile" } },
|
||||
{ name: "general", href: "/settings/my-account/general", trackingMetadata: { section: "my_account", page: "general" } },
|
||||
{ name: "calendars", href: "/settings/my-account/calendars", trackingMetadata: { section: "my_account", page: "calendars" } },
|
||||
{ name: "conferencing", href: "/settings/my-account/conferencing", trackingMetadata: { section: "my_account", page: "conferencing" } },
|
||||
{ name: "appearance", href: "/settings/my-account/appearance", trackingMetadata: { section: "my_account", page: "appearance" } },
|
||||
{ name: "out_of_office", href: "/settings/my-account/out-of-office", trackingMetadata: { section: "my_account", page: "out_of_office" } },
|
||||
{ name: "push_notifications", href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" } },
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/my-account/profile",
|
||||
trackingMetadata: { section: "my_account", page: "profile" },
|
||||
},
|
||||
{
|
||||
name: "general",
|
||||
href: "/settings/my-account/general",
|
||||
trackingMetadata: { section: "my_account", page: "general" },
|
||||
},
|
||||
{
|
||||
name: "calendars",
|
||||
href: "/settings/my-account/calendars",
|
||||
trackingMetadata: { section: "my_account", page: "calendars" },
|
||||
},
|
||||
{
|
||||
name: "conferencing",
|
||||
href: "/settings/my-account/conferencing",
|
||||
trackingMetadata: { section: "my_account", page: "conferencing" },
|
||||
},
|
||||
{
|
||||
name: "appearance",
|
||||
href: "/settings/my-account/appearance",
|
||||
trackingMetadata: { section: "my_account", page: "appearance" },
|
||||
},
|
||||
{
|
||||
name: "out_of_office",
|
||||
href: "/settings/my-account/out-of-office",
|
||||
trackingMetadata: { section: "my_account", page: "out_of_office" },
|
||||
},
|
||||
{
|
||||
name: "push_notifications",
|
||||
href: "/settings/my-account/push-notifications",
|
||||
trackingMetadata: { section: "my_account", page: "push_notifications" },
|
||||
},
|
||||
// TODO
|
||||
// { name: "referrals", href: "/settings/my-account/referrals" },
|
||||
],
|
||||
@@ -53,16 +81,34 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
href: "/settings/security",
|
||||
icon: "key",
|
||||
children: [
|
||||
{ name: "password", href: "/settings/security/password", trackingMetadata: { section: "security", page: "password" } },
|
||||
{ name: "impersonation", href: "/settings/security/impersonation", trackingMetadata: { section: "security", page: "impersonation" } },
|
||||
{ name: "2fa_auth", href: "/settings/security/two-factor-auth", trackingMetadata: { section: "security", page: "2fa_auth" } },
|
||||
{
|
||||
name: "password",
|
||||
href: "/settings/security/password",
|
||||
trackingMetadata: { section: "security", page: "password" },
|
||||
},
|
||||
{
|
||||
name: "impersonation",
|
||||
href: "/settings/security/impersonation",
|
||||
trackingMetadata: { section: "security", page: "impersonation" },
|
||||
},
|
||||
{
|
||||
name: "2fa_auth",
|
||||
href: "/settings/security/two-factor-auth",
|
||||
trackingMetadata: { section: "security", page: "2fa_auth" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: "credit-card",
|
||||
children: [{ name: "manage_billing", href: "/settings/billing", trackingMetadata: { section: "billing", page: "manage_billing" } }],
|
||||
children: [
|
||||
{
|
||||
name: "manage_billing",
|
||||
href: "/settings/billing",
|
||||
trackingMetadata: { section: "billing", page: "manage_billing" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
@@ -70,9 +116,21 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
icon: "terminal",
|
||||
children: [
|
||||
//
|
||||
{ name: "webhooks", href: "/settings/developer/webhooks", trackingMetadata: { section: "developer", page: "webhooks" } },
|
||||
{ name: "api_keys", href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" } },
|
||||
{ name: "admin_api", href: "/settings/organizations/admin-api", trackingMetadata: { section: "developer", page: "admin_api" } },
|
||||
{
|
||||
name: "webhooks",
|
||||
href: "/settings/developer/webhooks",
|
||||
trackingMetadata: { section: "developer", page: "webhooks" },
|
||||
},
|
||||
{
|
||||
name: "api_keys",
|
||||
href: "/settings/developer/api-keys",
|
||||
trackingMetadata: { section: "developer", page: "api_keys" },
|
||||
},
|
||||
{
|
||||
name: "admin_api",
|
||||
href: "/settings/organizations/admin-api",
|
||||
trackingMetadata: { section: "developer", page: "admin_api" },
|
||||
},
|
||||
// TODO: Add profile level for embeds
|
||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||
],
|
||||
@@ -97,13 +155,13 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
},
|
||||
...(orgBranding
|
||||
? [
|
||||
{
|
||||
name: "members",
|
||||
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "organization", page: "members" },
|
||||
},
|
||||
]
|
||||
{
|
||||
name: "members",
|
||||
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "organization", page: "members" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "privacy_and_security",
|
||||
@@ -146,31 +204,83 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
icon: "lock",
|
||||
children: [
|
||||
//
|
||||
{ name: "features", href: "/settings/admin/flags", trackingMetadata: { section: "admin", page: "features" } },
|
||||
{ name: "license", href: "/auth/setup?step=1", trackingMetadata: { section: "admin", page: "license" } },
|
||||
{ name: "impersonation", href: "/settings/admin/impersonation", trackingMetadata: { section: "admin", page: "impersonation" } },
|
||||
{ name: "apps", href: "/settings/admin/apps/calendar", trackingMetadata: { section: "admin", page: "apps" } },
|
||||
{ name: "users", href: "/settings/admin/users", trackingMetadata: { section: "admin", page: "users" } },
|
||||
{ name: "organizations", href: "/settings/admin/organizations", trackingMetadata: { section: "admin", page: "organizations" } },
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS", trackingMetadata: { section: "admin", page: "locked_sms" } },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth", trackingMetadata: { section: "admin", page: "oauth" } },
|
||||
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms", trackingMetadata: { section: "admin", page: "workspace_platforms" } },
|
||||
{ name: "Playground", href: "/settings/admin/playground", trackingMetadata: { section: "admin", page: "playground" } },
|
||||
{
|
||||
name: "features",
|
||||
href: "/settings/admin/flags",
|
||||
trackingMetadata: { section: "admin", page: "features" },
|
||||
},
|
||||
{
|
||||
name: "license",
|
||||
href: "/auth/setup?step=1",
|
||||
trackingMetadata: { section: "admin", page: "license" },
|
||||
},
|
||||
{
|
||||
name: "impersonation",
|
||||
href: "/settings/admin/impersonation",
|
||||
trackingMetadata: { section: "admin", page: "impersonation" },
|
||||
},
|
||||
{
|
||||
name: "apps",
|
||||
href: "/settings/admin/apps/calendar",
|
||||
trackingMetadata: { section: "admin", page: "apps" },
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
href: "/settings/admin/users",
|
||||
trackingMetadata: { section: "admin", page: "users" },
|
||||
},
|
||||
{
|
||||
name: "organizations",
|
||||
href: "/settings/admin/organizations",
|
||||
trackingMetadata: { section: "admin", page: "organizations" },
|
||||
},
|
||||
{
|
||||
name: "lockedSMS",
|
||||
href: "/settings/admin/lockedSMS",
|
||||
trackingMetadata: { section: "admin", page: "locked_sms" },
|
||||
},
|
||||
{
|
||||
name: "oAuth",
|
||||
href: "/settings/admin/oAuth",
|
||||
trackingMetadata: { section: "admin", page: "oauth" },
|
||||
},
|
||||
{
|
||||
name: "Workspace Platforms",
|
||||
href: "/settings/admin/workspace-platforms",
|
||||
trackingMetadata: { section: "admin", page: "workspace_platforms" },
|
||||
},
|
||||
{
|
||||
name: "Playground",
|
||||
href: "/settings/admin/playground",
|
||||
trackingMetadata: { section: "admin", page: "playground" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
tabs.find((tab) => {
|
||||
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
|
||||
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso", trackingMetadata: { section: "security", page: "sso_configuration" } });
|
||||
tab.children?.push({
|
||||
name: "sso_configuration",
|
||||
href: "/settings/security/sso",
|
||||
trackingMetadata: { section: "security", page: "sso_configuration" },
|
||||
});
|
||||
// TODO: Enable dsync for self hosters
|
||||
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
|
||||
}
|
||||
if (tab.name === "admin" && IS_CALCOM) {
|
||||
tab.children?.push({ name: "create_org", href: "/settings/organizations/new", trackingMetadata: { section: "admin", page: "create_org" } });
|
||||
tab.children?.push({
|
||||
name: "create_org",
|
||||
href: "/settings/organizations/new",
|
||||
trackingMetadata: { section: "admin", page: "create_org" },
|
||||
});
|
||||
}
|
||||
if (tab.name === "admin" && IS_CALCOM) {
|
||||
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new", trackingMetadata: { section: "admin", page: "create_license_key" } });
|
||||
tab.children?.push({
|
||||
name: "create_license_key",
|
||||
href: "/settings/license-key/new",
|
||||
trackingMetadata: { section: "admin", page: "create_license_key" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -442,8 +552,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
setTeamMenuState(newTeamMenuState);
|
||||
}
|
||||
}}
|
||||
aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
|
||||
}`}>
|
||||
aria-label={`${team.name} ${
|
||||
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
|
||||
}`}>
|
||||
<div className="me-3">
|
||||
{teamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
@@ -451,7 +562,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{ }
|
||||
{}
|
||||
{!team.parentId && (
|
||||
<Image
|
||||
src={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
@@ -469,7 +580,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="stack-y-0.5" id={`team-content-${team.id}`}>
|
||||
<CollapsibleContent className="flex flex-col space-y-1" id={`team-content-${team.id}`}>
|
||||
{team.accepted && (
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
@@ -494,9 +605,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore this exists wtf?
|
||||
(team.isOrgAdmin && team.isOrgAdmin)) && (
|
||||
<>
|
||||
{/* TODO */}
|
||||
{/* <VerticalTabItem
|
||||
<>
|
||||
{/* TODO */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("general")}
|
||||
href={`${WEBAPP_URL}/settings/my-account/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
@@ -628,7 +739,7 @@ const SettingsSidebarContainer = ({
|
||||
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
{ }
|
||||
{}
|
||||
{!tab.icon && tab?.avatar && (
|
||||
<Image
|
||||
width={16}
|
||||
@@ -647,7 +758,7 @@ const SettingsSidebarContainer = ({
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack-y-px">
|
||||
<div className="flex flex-col space-y-1">
|
||||
{tab.children?.map((child, index) => (
|
||||
<div key={child.href} className="flex items-start gap-2">
|
||||
<VerticalTabItem
|
||||
@@ -772,10 +883,11 @@ const SettingsSidebarContainer = ({
|
||||
setOtherTeamMenuState(newOtherTeamMenuState);
|
||||
}
|
||||
}}
|
||||
aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen
|
||||
? t("collapse_menu")
|
||||
: t("expand_menu")
|
||||
}`}>
|
||||
aria-label={`${otherTeam.name} ${
|
||||
otherTeamMenuState[index].teamMenuOpen
|
||||
? t("collapse_menu")
|
||||
: t("expand_menu")
|
||||
}`}>
|
||||
<div className="me-3">
|
||||
{otherTeamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
@@ -783,7 +895,7 @@ const SettingsSidebarContainer = ({
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{ }
|
||||
{}
|
||||
{!otherTeam.parentId && (
|
||||
<Image
|
||||
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
|
||||
@@ -797,12 +909,16 @@ const SettingsSidebarContainer = ({
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className="stack-y-0.5"
|
||||
className="flex flex-col space-y-1"
|
||||
id={`other-team-content-${otherTeam.id}`}>
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
trackingMetadata={{ section: "other_team", page: "profile", teamId: otherTeam.id }}
|
||||
trackingMetadata={{
|
||||
section: "other_team",
|
||||
page: "profile",
|
||||
teamId: otherTeam.id,
|
||||
}}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
@@ -810,7 +926,11 @@ const SettingsSidebarContainer = ({
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
trackingMetadata={{ section: "other_team", page: "members", teamId: otherTeam.id }}
|
||||
trackingMetadata={{
|
||||
section: "other_team",
|
||||
page: "members",
|
||||
teamId: otherTeam.id,
|
||||
}}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
|
||||
@@ -29,7 +29,7 @@ async function handler(req: NextRequest) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(ip),
|
||||
identifier: `forgotPassword:${piiHasher.hash(ip)}`,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
|
||||
|
||||
const travelScheduleSelect = {
|
||||
id: true,
|
||||
@@ -40,7 +40,8 @@ async function postHandler(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
|
||||
const scheduleRepository = new ScheduleRepository(prisma);
|
||||
const defaultScheduleId = await scheduleRepository.getDefaultScheduleId(user.id);
|
||||
|
||||
if (!user.defaultScheduleId) {
|
||||
// set default schedule if not already set
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -43,6 +43,7 @@ async function getHandler(req: NextRequest) {
|
||||
|
||||
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
|
||||
if (isOnboardingV3Enabled && organizationOnboardingId) {
|
||||
params.append("fromTeamOnboarding", "true");
|
||||
const redirectUrl = new URL(
|
||||
`/onboarding/personal/settings?${params.toString()}`,
|
||||
WEBAPP_URL
|
||||
|
||||
@@ -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,11 @@ 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,14 +76,14 @@ 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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +139,10 @@ async function handleBookingAction(
|
||||
} catch (e) {
|
||||
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)}`);
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
@@ -148,7 +150,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`);
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -184,8 +184,7 @@ export const AppPage = ({
|
||||
enabled: !!dependencies,
|
||||
});
|
||||
|
||||
const disableInstall =
|
||||
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
|
||||
const disableInstall = dependencyData.data ? dependencyData.data.some((dependency) => !dependency.installed) : false;
|
||||
|
||||
// const disableInstall = requiresGCal && !gCalInstalled.data;
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { InstallAppButtonChild } from "./InstallAppButtonChild";
|
||||
|
||||
// Mock credential type
|
||||
type MockCredential = {
|
||||
id: number;
|
||||
delegatedToId: string;
|
||||
userId: number;
|
||||
user: { email: string };
|
||||
key: { access_token: string };
|
||||
invalid: boolean;
|
||||
teamId: null;
|
||||
team: null;
|
||||
delegationCredentialId: string;
|
||||
type:
|
||||
| `${string}_calendar`
|
||||
| `${string}_messaging`
|
||||
| `${string}_payment`
|
||||
| `${string}_video`
|
||||
| `${string}_other`
|
||||
| `${string}_automation`
|
||||
| `${string}_analytics`
|
||||
| `${string}_crm`
|
||||
| `${string}_other_calendar`;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
// Factory function to create mock credentials
|
||||
const createMockCredential = (overrides: Partial<MockCredential> = {}): MockCredential => ({
|
||||
id: 1,
|
||||
type: "google_calendar" as const,
|
||||
userId: 1,
|
||||
delegatedToId: "delegation-123",
|
||||
teamId: null,
|
||||
team: null,
|
||||
user: { email: "test@example.com" },
|
||||
key: { access_token: "mock_token_123" },
|
||||
delegationCredentialId: "delegation-123",
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock the useLocale hook
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({
|
||||
t: (key: string) => {
|
||||
const translations = {
|
||||
install_app: "Install App",
|
||||
install_another: "Install Another",
|
||||
start_paid_trial: "Start Free Trial",
|
||||
subscribe: "Subscribe",
|
||||
};
|
||||
return translations[key as keyof typeof translations] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("InstallAppButtonChild", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("is disabled when credentials exist and multiInstall is false", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild multiInstall={false} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is enabled when credentials exist and multiInstall is true", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild multiInstall={true} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("is disabled when disabled prop is true regardless of credentials", () => {
|
||||
render(<InstallAppButtonChild disabled={true} multiInstall={true} credentials={[]} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("combines disabled prop with credential logic correctly", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild disabled={true} multiInstall={false} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is enabled when no blocking conditions exist", () => {
|
||||
render(<InstallAppButtonChild multiInstall={false} credentials={[]} disabled={false} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const InstallAppButtonChild = ({
|
||||
const { t } = useLocale();
|
||||
|
||||
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||
const isDisabled = shouldDisableInstallation || props.disabled;
|
||||
|
||||
// Paid apps don't support team installs at the moment
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
@@ -25,7 +26,7 @@ export const InstallAppButtonChild = ({
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
size="base">
|
||||
{paid.trial ? t("start_paid_trial") : t("subscribe")}
|
||||
@@ -37,7 +38,7 @@ export const InstallAppButtonChild = ({
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { ConfirmDialog } from "./confirmDialog";
|
||||
|
||||
interface IWipeMyCalActionButtonProps {
|
||||
className?: string;
|
||||
bookingsEmpty: boolean;
|
||||
bookingStatus: "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed";
|
||||
}
|
||||
|
||||
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
||||
const { bookingsEmpty, bookingStatus } = props;
|
||||
const { className, bookingsEmpty, bookingStatus } = props;
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const { isSuccess, isPending, data } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "other",
|
||||
@@ -28,7 +29,7 @@ const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
||||
return (
|
||||
<>
|
||||
{data && isSuccess && !isPending && credentialId && (
|
||||
<div className="mb-4">
|
||||
<div className={className}>
|
||||
<ConfirmDialog isOpenDialog={openDialog} setIsOpenDialog={setOpenDialog} />
|
||||
<Button color="primary" onClick={() => setOpenDialog(true)} data-testid="wipe-today-button">
|
||||
Wipe Today
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
|
||||
|
||||
interface AcceptBookingButtonProps {
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
recurringEventId?: string | null;
|
||||
isRecurring?: boolean;
|
||||
isTabRecurring?: boolean;
|
||||
isTabUnconfirmed?: boolean;
|
||||
size?: "sm" | "base" | "lg";
|
||||
color?: "primary" | "secondary" | "minimal" | "destructive";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AcceptBookingButton({
|
||||
bookingId,
|
||||
bookingUid,
|
||||
recurringEventId,
|
||||
isRecurring = false,
|
||||
isTabRecurring = false,
|
||||
isTabUnconfirmed = false,
|
||||
size = "base",
|
||||
color = "primary",
|
||||
className,
|
||||
}: AcceptBookingButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { bookingConfirm, isPending } = useBookingConfirmation({
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
});
|
||||
|
||||
const confirmLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm");
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={() => bookingConfirm({ bookingId, confirmed: true, recurringEventId })}
|
||||
disabled={isPending}
|
||||
data-booking-uid={bookingUid}
|
||||
data-testid="confirm">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -8,7 +8,6 @@ import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/a
|
||||
import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -23,7 +22,6 @@ import type { Ensure } from "@calcom/types/utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -35,30 +33,30 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
|
||||
import { TableActions } from "@calcom/ui/components/table";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
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";
|
||||
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
|
||||
import {
|
||||
useBookingActionsStoreContext,
|
||||
BookingActionsStoreProvider,
|
||||
} from "./actions/BookingActionsStoreProvider";
|
||||
import {
|
||||
getPendingActions,
|
||||
getCancelEventAction,
|
||||
shouldShowPendingActions,
|
||||
shouldShowRecurringCancelAction,
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
getReportAction,
|
||||
isActionDisabled,
|
||||
} from "./actions/bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
@@ -139,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;
|
||||
@@ -146,36 +145,26 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
};
|
||||
});
|
||||
// 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;
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
// 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,
|
||||
}));
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
|
||||
@@ -217,22 +206,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
@@ -265,20 +238,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
t,
|
||||
} as BookingActionContext;
|
||||
|
||||
const basePendingActions = getPendingActions(actionContext);
|
||||
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reject"
|
||||
? () => setRejectionDialogIsOpen(true)
|
||||
: action.id === "confirm"
|
||||
? () => bookingConfirm(true)
|
||||
: undefined,
|
||||
disabled: action.disabled || mutation.isPending,
|
||||
})) as ActionType[];
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
@@ -313,6 +272,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
@@ -321,232 +281,243 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
className="hover:bg-cal-muted group w-full">
|
||||
<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">
|
||||
{eventTypeColor && (
|
||||
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
|
||||
)}
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label ||
|
||||
(typeof locationToDisplay === "string" &&
|
||||
locationToDisplay?.startsWith("https://"))) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider?.label })
|
||||
: t("join_meeting")}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{showPendingPayment && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={itemRef}
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
data-booking-list-item="true"
|
||||
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">
|
||||
{eventTypeColor && <div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />}
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
|
||||
<div className="cursor-pointer py-4">
|
||||
<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",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
<span> </span>
|
||||
|
||||
{showPendingPayment && (
|
||||
<Badge className="hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</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"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label ||
|
||||
(typeof locationToDisplay === "string" && locationToDisplay?.startsWith("https://"))) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider?.label })
|
||||
: t("join_meeting")}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
<div className="stack-y-2 sm:stack-y-0 flex w-full flex-col flex-wrap items-end justify-end space-x-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
<BookingActionsDropdown booking={booking} context="list" />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className="min-h-[34px] min-w-[34px]"
|
||||
tooltip={reportActionWithHandler.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BookingItemBadges
|
||||
booking={booking}
|
||||
isPending={isPending}
|
||||
recurringDates={recurringDates}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
<div
|
||||
data-testid="title-and-attendees"
|
||||
className={classNames("flex-1 px-4", isRejected && "line-through")}>
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="flex h-full flex-col">
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{showPendingPayment && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"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}
|
||||
<span> </span>
|
||||
|
||||
{showPendingPayment && (
|
||||
<Badge className="hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
<div className="flex flex-col flex-wrap items-end justify-end gap-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-shrink-0 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && (
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<RejectBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={isRecurring}
|
||||
isTabRecurring={isTabRecurring}
|
||||
isTabUnconfirmed={isTabUnconfirmed}
|
||||
/>
|
||||
<AcceptBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={isRecurring}
|
||||
isTabRecurring={isTabRecurring}
|
||||
isTabUnconfirmed={isTabUnconfirmed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowRecurringCancelAction(actionContext) && (
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
key="cancel"
|
||||
data-testid="cancel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCancelDialogOpen(true);
|
||||
}}
|
||||
StartIcon="circle-x"
|
||||
disabled={isActionDisabled("cancel", actionContext)}
|
||||
data-booking-uid={booking.uid}
|
||||
color="destructive">
|
||||
{t("cancel_all_remaining")}
|
||||
</Button>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className="min-h-[34px] min-w-[34px]"
|
||||
tooltip={reportActionWithHandler.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BookingActionsDropdown booking={booking} context="list" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<BookingItemBadges
|
||||
booking={booking}
|
||||
isPending={isPending}
|
||||
isRejected={isRejected}
|
||||
recurringDates={recurringDates}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BookingItemBadges = ({
|
||||
booking,
|
||||
isPending,
|
||||
isRejected,
|
||||
recurringDates,
|
||||
userTimeFormat,
|
||||
userTimeZone,
|
||||
@@ -554,6 +525,7 @@ const BookingItemBadges = ({
|
||||
}: {
|
||||
booking: BookingItemProps;
|
||||
isPending: boolean;
|
||||
isRejected: boolean;
|
||||
recurringDates: Date[] | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
@@ -575,6 +547,11 @@ const BookingItemBadges = ({
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRejected && !isRescheduled && booking.assignmentReason.length === 0 && (
|
||||
<Badge variant="gray" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("rejected")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
@@ -727,21 +704,13 @@ const FirstAttendee = ({
|
||||
);
|
||||
};
|
||||
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
type NoShowProps = {
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
};
|
||||
|
||||
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber } = attendeeProps;
|
||||
const Attendee = (attendeeProps: BookingAttendee & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber, user } = attendeeProps;
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -758,6 +727,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = user?.name || name || user?.email || email;
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -767,10 +738,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
{displayName} <Icon name="eye-off" className="inline h-4" />
|
||||
</>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
<>{displayName}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -825,20 +796,12 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
};
|
||||
|
||||
type GroupedAttendeeProps = {
|
||||
attendees: AttendeeProps[];
|
||||
attendees: BookingAttendee[];
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
const { bookingUid } = groupedAttendeeProps;
|
||||
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
const { bookingUid, attendees } = groupedAttendeeProps;
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
@@ -850,11 +813,14 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit } = useForm<{
|
||||
attendees: AttendeeProps[];
|
||||
}>({
|
||||
|
||||
type FormValues = {
|
||||
attendees: Array<{ id: number; email: string; noShow: boolean }>;
|
||||
};
|
||||
|
||||
const { control, handleSubmit } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
attendees,
|
||||
attendees: attendees.map((a) => ({ id: a.id, email: a.email, noShow: a.noShow || false })),
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
@@ -864,8 +830,11 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
name: "attendees",
|
||||
});
|
||||
|
||||
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
|
||||
const filteredData = data.attendees.slice(1);
|
||||
const onSubmit = (data: FormValues) => {
|
||||
const filteredData = data.attendees.slice(1).map((attendee) => ({
|
||||
email: attendee.email,
|
||||
noShow: attendee.noShow,
|
||||
}));
|
||||
noShowMutation.mutate({ bookingUid, attendees: filteredData });
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
@@ -882,30 +851,46 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
{t("plus_more", { count: attendees.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent className="min-w-[300px]">
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">
|
||||
{t("mark_as_no_show_title")}
|
||||
</DropdownMenuLabel>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.slice(1).map((field, index) => (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="pr-8 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<span className={value ? "line-through" : ""}>{field.email}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{fields.slice(1).map((field, index) => {
|
||||
const attendee = attendees[index + 1];
|
||||
const displayName =
|
||||
attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
|
||||
const hasName = attendee.name || attendee.user?.name;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<div className={`w-full ${value ? "line-through" : ""}`}>
|
||||
{hasName ? (
|
||||
<>
|
||||
<div>{displayName}</div>
|
||||
<div className="text-subtle text-xs">{field.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<div>{field.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
@@ -924,7 +909,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const GroupedGuests = ({ guests }: { guests: BookingAttendee[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
@@ -944,23 +929,37 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
{t("plus_more", { count: guests.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent className="min-w-[300px]">
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
|
||||
{guests.slice(1).map((guest) => (
|
||||
<DropdownMenuItem key={guest.id}>
|
||||
<DropdownItem
|
||||
className="pr-6 focus:outline-none"
|
||||
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedEmail(guest.email);
|
||||
}}>
|
||||
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{guests.slice(1).map((guest) => {
|
||||
const displayName = guest.user?.name || guest.name || guest.user?.email || guest.email;
|
||||
const hasName = guest.name || guest.user?.name;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={guest.id}>
|
||||
<DropdownItem
|
||||
className="pr-6 focus:outline-none"
|
||||
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedEmail(guest.email);
|
||||
}}>
|
||||
<div className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>
|
||||
{hasName ? (
|
||||
<>
|
||||
<div>{displayName}</div>
|
||||
<div className="text-subtle text-xs">{guest.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<div>{guest.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end space-x-2 p-2">
|
||||
<div className="flex justify-end gap-2 p-2">
|
||||
<Link href={`mailto:${selectedEmail}`}>
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -995,7 +994,7 @@ const DisplayAttendees = ({
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
attendees: BookingAttendee[];
|
||||
user: UserProps | null;
|
||||
currentEmail?: string | null;
|
||||
bookingUid: string;
|
||||
@@ -1005,7 +1004,7 @@ const DisplayAttendees = ({
|
||||
attendees.sort((a, b) => a.id - b.id);
|
||||
|
||||
return (
|
||||
<div className="text-emphasis text-sm">
|
||||
<div className="text-emphasis text-sm" onClick={(e) => e.stopPropagation()}>
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
|
||||
@@ -7,9 +7,11 @@ import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface InternalNotePresetsSelectProps {
|
||||
internalNotePresets: { id: number; name: string }[];
|
||||
@@ -40,7 +42,7 @@ const InternalNotePresetsSelect = ({
|
||||
setCancellationReason("");
|
||||
} else {
|
||||
setShowOtherInput(false);
|
||||
onPresetSelect && onPresetSelect(option);
|
||||
onPresetSelect?.(option);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +52,7 @@ const InternalNotePresetsSelect = ({
|
||||
<Select
|
||||
className="mb-2"
|
||||
options={[
|
||||
...internalNotePresets?.map((preset) => ({
|
||||
...internalNotePresets.map((preset) => ({
|
||||
label: preset.name,
|
||||
value: preset.id,
|
||||
})),
|
||||
@@ -105,7 +107,10 @@ type Props = {
|
||||
};
|
||||
isHost: boolean;
|
||||
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
|
||||
renderContext: "booking-single-view" | "dialog";
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
showErrorAsToast?: boolean;
|
||||
onCanceled?: () => void;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
@@ -168,12 +173,13 @@ export default function CancelBooking(props: Props) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const isRenderedAsCancelDialog = props.renderContext === "dialog";
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
{error && !props.showErrorAsToast && (
|
||||
<div className="mt-8">
|
||||
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="x" className="h-6 w-6 text-red-600" />
|
||||
@@ -186,7 +192,7 @@ export default function CancelBooking(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<div className={classNames(isRenderedAsCancelDialog ? "-mt-2 mb-8" : "mt-5 sm:mt-6")}>
|
||||
{props.isHost && props.internalNotePresets.length > 0 && (
|
||||
<>
|
||||
<InternalNotePresetsSelect
|
||||
@@ -219,13 +225,13 @@ export default function CancelBooking(props: Props) {
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mb-4 mt-2 w-full "
|
||||
className={classNames("mb-4 w-full", !isRenderedAsCancelDialog && "mt-2")}
|
||||
rows={3}
|
||||
/>
|
||||
{isCancellationUserHost ? (
|
||||
<div className="-mt-2 mb-4 flex items-center gap-2">
|
||||
<Icon name="info" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-subtle text-sm leading-none">
|
||||
<p className="text-subtle text-sm leading-none">
|
||||
{t("notify_attendee_cancellation_reason_warning")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -248,7 +254,11 @@ export default function CancelBooking(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<div
|
||||
className={classNames(
|
||||
"ml-auto flex w-full space-x-4",
|
||||
isRenderedAsCancelDialog && "justify-end"
|
||||
)}>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
@@ -295,15 +305,23 @@ export default function CancelBooking(props: Props) {
|
||||
booking: bookingWithCancellationReason,
|
||||
});
|
||||
refreshData();
|
||||
if (props.onCanceled) {
|
||||
props.onCanceled();
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setLoading(false);
|
||||
setError(
|
||||
const errorMessage =
|
||||
data.message ||
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`
|
||||
);
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`;
|
||||
|
||||
if (props.showErrorAsToast) {
|
||||
showToast(errorMessage, "error");
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { RejectionReasonDialog } from "../dialog/RejectionReasonDialog";
|
||||
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
|
||||
|
||||
interface RejectBookingButtonProps {
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
recurringEventId?: string | null;
|
||||
isRecurring?: boolean;
|
||||
isTabRecurring?: boolean;
|
||||
isTabUnconfirmed?: boolean;
|
||||
size?: "sm" | "base" | "lg";
|
||||
color?: "primary" | "secondary" | "minimal" | "destructive";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RejectBookingButton({
|
||||
bookingId,
|
||||
bookingUid,
|
||||
recurringEventId,
|
||||
isRecurring = false,
|
||||
isTabRecurring = false,
|
||||
isTabUnconfirmed = false,
|
||||
size = "base",
|
||||
color = "secondary",
|
||||
className,
|
||||
}: RejectBookingButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { bookingConfirm, handleReject, isPending, rejectionDialogIsOpen, setRejectionDialogIsOpen } =
|
||||
useBookingConfirmation({
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
});
|
||||
|
||||
const rejectLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject");
|
||||
|
||||
return (
|
||||
<>
|
||||
<RejectionReasonDialog
|
||||
isOpenDialog={rejectionDialogIsOpen}
|
||||
setIsOpenDialog={setRejectionDialogIsOpen}
|
||||
onConfirm={(reason) => bookingConfirm({ bookingId, confirmed: false, recurringEventId, reason })}
|
||||
isPending={isPending}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleReject}
|
||||
disabled={isPending}
|
||||
data-booking-uid={bookingUid}
|
||||
data-testid="reject">
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,12 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
{/* TODAY header */}
|
||||
<li className="bg-muted flex items-center px-6 py-4">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
<SkeletonHeader />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
||||
{/* NEXT header */}
|
||||
<li className="bg-muted px-4 py-2">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
<SkeletonHeader />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
@@ -25,6 +19,14 @@ function SkeletonLoader() {
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonHeader() {
|
||||
return (
|
||||
<li className="bg-muted flex items-center px-6 py-4">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
|
||||
|
||||
@@ -125,6 +125,7 @@ const mockProps = {
|
||||
eventType: {},
|
||||
},
|
||||
internalNotePresets: [],
|
||||
renderContext: "booking-single-view" as const,
|
||||
};
|
||||
|
||||
const mockBookingWithoutCancellationFee = {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -19,18 +19,20 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { CancelBookingDialog } from "@components/dialog/CancelBookingDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { RejectionReasonDialog } from "@components/dialog/RejectionReasonDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { useBookingConfirmation } from "../hooks/useBookingConfirmation";
|
||||
import type { BookingItemProps } from "../types";
|
||||
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
@@ -72,11 +74,24 @@ export function BookingActionsDropdown({
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Use store for all dialog states
|
||||
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
|
||||
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
|
||||
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
|
||||
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
// Use the booking confirmation hook for confirm/reject logic
|
||||
const {
|
||||
bookingConfirm,
|
||||
handleReject,
|
||||
rejectionDialogIsOpen,
|
||||
setRejectionDialogIsOpen,
|
||||
isPending: isConfirmPending,
|
||||
} = useBookingConfirmation({
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
});
|
||||
|
||||
// Use store for all other dialog states
|
||||
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
|
||||
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
|
||||
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
@@ -105,6 +120,8 @@ export function BookingActionsDropdown({
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
|
||||
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
|
||||
const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen);
|
||||
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
|
||||
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
@@ -128,23 +145,6 @@ export function BookingActionsDropdown({
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
@@ -170,7 +170,6 @@ export function BookingActionsDropdown({
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
@@ -179,15 +178,15 @@ export function BookingActionsDropdown({
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
|
||||
const userEmail = booking.loggedInUser.userEmail;
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
const isAttendee = !!userSeat;
|
||||
|
||||
// Check if the logged-in user is the host/owner of the booking
|
||||
const isHost = booking.loggedInUser.userId === booking.user?.id;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
@@ -200,19 +199,6 @@ export function BookingActionsDropdown({
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
@@ -268,12 +254,17 @@ export function BookingActionsDropdown({
|
||||
shouldShowPending && context === "details" ? getPendingActions(actionContext) : [];
|
||||
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
|
||||
...action,
|
||||
disabled: mutation.isPending,
|
||||
disabled: isConfirmPending,
|
||||
onClick:
|
||||
action.id === "confirm"
|
||||
? () => bookingConfirm(true)
|
||||
? () =>
|
||||
bookingConfirm({
|
||||
bookingId: booking.id,
|
||||
confirmed: true,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
})
|
||||
: action.id === "reject"
|
||||
? () => setRejectionDialogIsOpen(true)
|
||||
? () => handleReject()
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
@@ -482,35 +473,39 @@ export function BookingActionsDropdown({
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CancelBookingDialog
|
||||
isOpenDialog={isCancelDialogOpen}
|
||||
setIsOpenDialog={setIsCancelDialogOpen}
|
||||
booking={{
|
||||
uid: booking.uid,
|
||||
id: booking.id,
|
||||
title: booking.title,
|
||||
startTime: new Date(booking.startTime),
|
||||
payment: booking.payment,
|
||||
}}
|
||||
profile={{
|
||||
name: booking.user?.name || null,
|
||||
slug: booking.user?.username || null,
|
||||
}}
|
||||
recurringEvent={booking.eventType?.recurringEvent || null}
|
||||
team={booking.eventType?.team?.name}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
allRemainingBookings={isTabRecurring && isRecurring}
|
||||
seatReferenceUid={getSeatReferenceUid()}
|
||||
currentUserEmail={booking.loggedInUser.userEmail}
|
||||
bookingCancelledEventProps={{
|
||||
booking: booking,
|
||||
organizer: {
|
||||
name: booking.user?.name || "Nameless",
|
||||
email: booking.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||
timeZone: booking.user?.timeZone,
|
||||
},
|
||||
eventType: booking.eventType,
|
||||
}}
|
||||
isHost={isHost}
|
||||
internalNotePresets={[]}
|
||||
eventTypeMetadata={booking.eventType?.metadata}
|
||||
/>
|
||||
{isBookingFromRoutingForm &&
|
||||
parsedBooking.eventType &&
|
||||
parsedBooking.eventType.id !== undefined &&
|
||||
@@ -575,6 +570,19 @@ export function BookingActionsDropdown({
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<RejectionReasonDialog
|
||||
isOpenDialog={rejectionDialogIsOpen}
|
||||
setIsOpenDialog={setRejectionDialogIsOpen}
|
||||
onConfirm={(reason) =>
|
||||
bookingConfirm({
|
||||
bookingId: booking.id,
|
||||
confirmed: false,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
isPending={isConfirmPending}
|
||||
/>
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
|
||||
<Button
|
||||
@@ -582,7 +590,7 @@ export function BookingActionsDropdown({
|
||||
color="secondary"
|
||||
size={size}
|
||||
StartIcon="ellipsis"
|
||||
className={className}
|
||||
className={classNames("px-2", className)}
|
||||
// Prevent click from bubbling to parent row/container click handlers
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
@@ -604,7 +612,7 @@ export function BookingActionsDropdown({
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
@@ -627,7 +635,7 @@ export function BookingActionsDropdown({
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
@@ -648,7 +656,7 @@ export function BookingActionsDropdown({
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
@@ -685,13 +693,12 @@ export function BookingActionsDropdown({
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEventAction.onClick?.(e);
|
||||
setIsCancelDialogOpen(true);
|
||||
}}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-booking-uid={cancelEventAction.bookingUid}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("Booking Actions", () => {
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
id: "confirm",
|
||||
bookingId: 1,
|
||||
bookingUid: "booking-123",
|
||||
label: "confirm",
|
||||
icon: "check",
|
||||
disabled: false,
|
||||
@@ -180,10 +180,10 @@ describe("Booking Actions", () => {
|
||||
expect(action).toEqual({
|
||||
id: "cancel",
|
||||
label: "cancel_event",
|
||||
href: "/booking/booking-123?cancel=true",
|
||||
icon: "circle-x",
|
||||
color: "destructive",
|
||||
disabled: false,
|
||||
bookingUid: "booking-123",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,7 +212,6 @@ describe("Booking Actions", () => {
|
||||
});
|
||||
const action = getCancelEventAction(context);
|
||||
|
||||
expect(action.href).toContain("allRemainingBookings=true");
|
||||
expect(action.label).toBe("cancel_all_remaining");
|
||||
});
|
||||
});
|
||||
@@ -394,7 +393,16 @@ describe("Booking Actions", () => {
|
||||
...createMockContext().booking,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
paid: true,
|
||||
payment: [{ paymentOption: "HOLD", amount: 1000, currency: "usd", success: true }],
|
||||
payment: [
|
||||
{
|
||||
paymentOption: "HOLD",
|
||||
amount: 1000,
|
||||
currency: "usd",
|
||||
success: true,
|
||||
appId: null,
|
||||
refunded: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const actions = getAfterEventActions(context);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -45,7 +46,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) {
|
||||
actions.push({
|
||||
id: "confirm",
|
||||
bookingId: booking.id,
|
||||
bookingUid: booking.uid,
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
icon: "check" as const,
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
@@ -63,18 +64,15 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
}
|
||||
|
||||
export function getCancelEventAction(context: BookingActionContext): ActionType {
|
||||
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;
|
||||
const seatReferenceUid = getSeatReferenceUid();
|
||||
const { booking, isTabRecurring, isRecurring, t } = context;
|
||||
|
||||
return {
|
||||
id: "cancel",
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}${booking.seatsReferences.length && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
|
||||
icon: "circle-x",
|
||||
color: "destructive",
|
||||
disabled: isActionDisabled("cancel", context),
|
||||
bookingUid: booking.uid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,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",
|
||||
@@ -134,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
|
||||
? {
|
||||
@@ -232,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":
|
||||
|
||||
@@ -16,6 +16,7 @@ export type BookingActionsStore = {
|
||||
isOpenAddGuestsDialog: boolean;
|
||||
isOpenReportDialog: boolean;
|
||||
rerouteDialogIsOpen: boolean;
|
||||
isCancelDialogOpen: boolean;
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -29,10 +30,7 @@ export type BookingActionsStore = {
|
||||
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: string;
|
||||
setRejectionReason: (reason: string) => void;
|
||||
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const createBookingActionsStore = () => {
|
||||
@@ -49,6 +47,7 @@ export const createBookingActionsStore = () => {
|
||||
isOpenAddGuestsDialog: false,
|
||||
isOpenReportDialog: false,
|
||||
rerouteDialogIsOpen: false,
|
||||
isCancelDialogOpen: false,
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: (isOpen) =>
|
||||
@@ -98,9 +97,9 @@ export const createBookingActionsStore = () => {
|
||||
set((state) => ({
|
||||
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
|
||||
})),
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: "",
|
||||
setRejectionReason: (reason: string) => set({ rejectionReason: reason }),
|
||||
setIsCancelDialogOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
isCancelDialogOpen: typeof isOpen === "function" ? isOpen(state.isCancelDialogOpen) : isOpen,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface UseBookingConfirmationOptions {
|
||||
isRecurring?: boolean;
|
||||
isTabRecurring?: boolean;
|
||||
isTabUnconfirmed?: boolean;
|
||||
}
|
||||
|
||||
interface BookingConfirmParams {
|
||||
bookingId: number;
|
||||
confirmed: boolean;
|
||||
recurringEventId?: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function useBookingConfirmation(options: UseBookingConfirmationOptions = {}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
|
||||
const { isRecurring = false, isTabRecurring = false, isTabUnconfirmed = false } = options;
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message || t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const bookingConfirm = ({ bookingId, confirmed, recurringEventId, reason }: BookingConfirmParams) => {
|
||||
let body = {
|
||||
bookingId,
|
||||
confirmed,
|
||||
reason: reason || "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring && recurringEventId) {
|
||||
body = Object.assign({}, body, { recurringEventId });
|
||||
}
|
||||
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
};
|
||||
|
||||
return {
|
||||
bookingConfirm,
|
||||
handleReject,
|
||||
rejectionDialogIsOpen,
|
||||
setRejectionDialogIsOpen,
|
||||
isPending: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
export type BookingListingStatus = NonNullable<
|
||||
RouterInputs["viewer"]["bookings"]["get"]["filters"]["statuses"]
|
||||
>[number];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import CancelBooking from "@components/booking/CancelBooking";
|
||||
|
||||
interface ICancelBookingDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
booking: {
|
||||
uid: string;
|
||||
id: number;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
payment?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
success: boolean;
|
||||
paymentOption?: string | null;
|
||||
appId?: string | null;
|
||||
refunded?: boolean;
|
||||
}[];
|
||||
};
|
||||
profile: {
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
};
|
||||
recurringEvent: RecurringEvent | null;
|
||||
team?: string | null;
|
||||
teamId?: number;
|
||||
allRemainingBookings?: boolean;
|
||||
seatReferenceUid?: string;
|
||||
currentUserEmail?: string;
|
||||
bookingCancelledEventProps: {
|
||||
booking: unknown;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
isHost: boolean;
|
||||
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
booking,
|
||||
profile,
|
||||
recurringEvent,
|
||||
team,
|
||||
teamId,
|
||||
allRemainingBookings = false,
|
||||
seatReferenceUid,
|
||||
currentUserEmail,
|
||||
bookingCancelledEventProps,
|
||||
isHost,
|
||||
internalNotePresets = [],
|
||||
eventTypeMetadata,
|
||||
} = props;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Get the first payment if it exists and map to expected format
|
||||
const payment =
|
||||
booking.payment && booking.payment.length > 0
|
||||
? {
|
||||
amount: booking.payment[0].amount,
|
||||
currency: booking.payment[0].currency,
|
||||
appId: booking.payment[0].appId || null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const handleCanceled = () => {
|
||||
utils.viewer.bookings.invalidate();
|
||||
showToast(t("booking_cancelled"), "success");
|
||||
setIsOpenDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow data-testid="cancel-dialog">
|
||||
<DialogHeader title={allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")} />
|
||||
<CancelBooking
|
||||
renderContext="dialog"
|
||||
booking={{
|
||||
uid: booking.uid,
|
||||
id: booking.id,
|
||||
title: booking.title,
|
||||
startTime: booking.startTime,
|
||||
payment: payment,
|
||||
}}
|
||||
profile={profile}
|
||||
recurringEvent={recurringEvent}
|
||||
team={team}
|
||||
teamId={teamId}
|
||||
setIsCancellationMode={setIsOpenDialog}
|
||||
theme={null}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
seatReferenceUid={seatReferenceUid}
|
||||
currentUserEmail={currentUserEmail}
|
||||
bookingCancelledEventProps={bookingCancelledEventProps}
|
||||
isHost={isHost}
|
||||
internalNotePresets={internalNotePresets}
|
||||
eventTypeMetadata={eventTypeMetadata}
|
||||
showErrorAsToast={true}
|
||||
onCanceled={handleCanceled}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
|
||||
interface RejectionReasonDialogProps {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
onConfirm: (reason: string) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function RejectionReasonDialog({
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: RejectionReasonDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpenDialog) {
|
||||
setRejectionReason("");
|
||||
}
|
||||
}, [isOpenDialog]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(rejectionReason);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> ({t("optional")})</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose color="secondary" />
|
||||
<Button disabled={isPending} data-testid="rejection-confirm" onClick={handleConfirm}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,8 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register(`${permissionKey}Read`)}
|
||||
id={`${permissionKey}Read`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
style={{ accentColor: "#ffffff" }}
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
@@ -94,7 +95,8 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register(`${permissionKey}Write`)}
|
||||
id={`${permissionKey}Write`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
style={{ accentColor: "#ffffff" }}
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
@@ -162,36 +164,6 @@ export const OAuthClientForm = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/** <div className="mt-6">
|
||||
<Controller
|
||||
control={control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label>Client logo</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={value}
|
||||
fallback={<Icon name="plus" className="text-subtle h-4 w-4" />}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="vatar-upload"
|
||||
buttonMsg="Upload"
|
||||
imageSrc={value}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
setValue("logo", newAvatar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="mt-6">
|
||||
<Tooltip content={t("booking_redirect_uri")}>
|
||||
<TextField
|
||||
@@ -229,7 +201,8 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register("areEmailsEnabled")}
|
||||
id="areEmailsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
style={{ accentColor: "#ffffff" }}
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
@@ -242,7 +215,8 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register("areCalendarEventsEnabled")}
|
||||
id="areCalendarEventsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
style={{ accentColor: "#ffffff" }}
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
@@ -263,7 +237,8 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register("areDefaultEventTypesEnabled")}
|
||||
id="areDefaultEventTypesEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
style={{ accentColor: "#ffffff" }}
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
@@ -16,12 +17,11 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
|
||||
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
|
||||
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
|
||||
|
||||
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
|
||||
|
||||
import { getThemeProviderProps } from "./getThemeProviderProps";
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
@@ -112,6 +112,7 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const isThemeSupported = useIsThemeSupported();
|
||||
const nuqsParams = useNuqsParams();
|
||||
|
||||
const RemainingProviders = (
|
||||
<>
|
||||
@@ -121,7 +122,7 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<NuqsAdapter>
|
||||
<NuqsAdapter {...nuqsParams}>
|
||||
<FeatureFlagsProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router (_app.tsx)
|
||||
*
|
||||
*
|
||||
* Currently only serves the /router endpoint (routing forms redirect page).
|
||||
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
|
||||
*
|
||||
*
|
||||
* For App Router, use app-providers-app-dir.tsx instead.
|
||||
*/
|
||||
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -15,7 +14,6 @@ import { appWithTranslation } from "next-i18next";
|
||||
import type { SSRConfig } from "next-i18next/dist/types/types";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import dynamic from "next/dynamic";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/pages";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
@@ -28,6 +26,7 @@ import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
@@ -61,7 +60,6 @@ export type AppProps = Omit<
|
||||
err?: Error;
|
||||
};
|
||||
|
||||
|
||||
type AppPropsWithChildren = AppProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -136,8 +134,8 @@ const enum ThemeSupport {
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
Pick<AppProps, "router"> &
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
>;
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
@@ -211,8 +209,8 @@ function getThemeProviderProps({
|
||||
? ThemeSupport.Booking
|
||||
: // if isThemeSupported is explicitly false, we don't use theme there
|
||||
props.isThemeSupported === false
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const themeBasis = props.themeBasis;
|
||||
@@ -236,13 +234,13 @@ function getThemeProviderProps({
|
||||
|
||||
const storageKey = isEmbedMode
|
||||
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
: themeSupport === ThemeSupport.App
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
@@ -278,6 +276,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
(typeof props.Component.isBookingPage === "function"
|
||||
? props.Component.isBookingPage({ router: props.router })
|
||||
: props.Component.isBookingPage) || isBookingPage;
|
||||
const nuqsParams = useNuqsParams();
|
||||
|
||||
const RemainingProviders = (
|
||||
<CustomI18nextProvider {...props}>
|
||||
@@ -287,7 +286,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<NuqsAdapter>
|
||||
<NuqsAdapter {...nuqsParams}>
|
||||
<FeatureFlagsProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
|
||||
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
hideOrganizerEmail: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
minimumRescheduleNotice: true,
|
||||
disableGuests: true,
|
||||
timeZone: true,
|
||||
profile: {
|
||||
|
||||
@@ -11,7 +11,7 @@ const assignmentReasonBadgeTitleMap = (assignmentReason: AssignmentReasonEnum) =
|
||||
case AssignmentReasonEnum.REROUTED:
|
||||
return "rerouted";
|
||||
case AssignmentReasonEnum.SALESFORCE_ASSIGNMENT:
|
||||
return "salesforce_assignment";
|
||||
return "salesforce_assigned";
|
||||
default:
|
||||
return "routed";
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// lets refactor and move this into packages/lib/hooks/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export default useMediaQuery;
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useNuqsParams() {
|
||||
const pathname = usePathname();
|
||||
const isBookingsPage = pathname?.startsWith("/bookings/");
|
||||
|
||||
return useMemo(() => {
|
||||
if (isBookingsPage) {
|
||||
return { processUrlSearchParams: putUidFirstInQueryParams };
|
||||
}
|
||||
return {};
|
||||
}, [isBookingsPage]);
|
||||
}
|
||||
|
||||
function putUidFirstInQueryParams(searchParams: URLSearchParams) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const entries = Array.from(params.entries());
|
||||
|
||||
// Clear existing params
|
||||
entries.forEach(([key]) => params.delete(key));
|
||||
|
||||
// Sort entries with "uid" first, then alphabetically
|
||||
const sortedEntries = entries.sort(([keyA], [keyB]) => {
|
||||
if (keyA === "uid") return -1;
|
||||
if (keyB === "uid") return 1;
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
// Re-add sorted entries
|
||||
sortedEntries.forEach(([key, value]) => params.append(key, value));
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
parent: {
|
||||
select: {
|
||||
@@ -86,6 +87,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
team,
|
||||
}),
|
||||
themeBasis: null,
|
||||
teamIsPrivate: team.isPrivate,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* TODO: Move it to features/booking-audit
|
||||
*/
|
||||
"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 { 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;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey: string }>;
|
||||
actor: {
|
||||
type: AuditActorType;
|
||||
displayName: string | null;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface BookingLogsFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
actorFilter: AuditActorType | null;
|
||||
onActorFilterChange: (value: AuditActorType | null) => void;
|
||||
actorOptions: Array<{ label: string; value: AuditActorType }>;
|
||||
}
|
||||
|
||||
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 getActorRoleLabel = (actorType: AuditActorType): string | null => {
|
||||
switch (actorType) {
|
||||
case "GUEST":
|
||||
return "Guest";
|
||||
case "ATTENDEE":
|
||||
return "Attendee";
|
||||
case "SYSTEM":
|
||||
return null;
|
||||
case "USER":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function BookingLogsFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
actorFilter,
|
||||
onActorFilterChange,
|
||||
actorOptions,
|
||||
}: BookingLogsFiltersProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-start">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<FilterSearchField
|
||||
size="sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
containerClassName=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onActorFilterChange((option.value as AuditActorType) || null);
|
||||
}}
|
||||
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</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());
|
||||
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (logId: string) => {
|
||||
setExpandedLogIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(logId)) {
|
||||
newSet.delete(logId);
|
||||
} else {
|
||||
newSet.add(logId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleJson = (logId: string) => {
|
||||
setShowJsonMap((prev) => ({
|
||||
...prev,
|
||||
[logId]: !prev[logId],
|
||||
}));
|
||||
};
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted">{t("no_audit_logs_found")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{logs.map((log, index) => {
|
||||
const isLast = index === logs.length - 1;
|
||||
const isExpanded = expandedLogIds.has(log.id);
|
||||
const showJson = showJsonMap[log.id] || false;
|
||||
const actorRole = getActorRoleLabel(log.actor.type);
|
||||
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px bg-subtle flex-1 min-h-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 rounded-lg py-2">
|
||||
<div className="px-3 mb-2">
|
||||
<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">
|
||||
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
||||
{log.actor.displayAvatar && (
|
||||
<Avatar
|
||||
size="xs"
|
||||
imageSrc={log.actor.displayAvatar}
|
||||
alt={log.actor.displayName || ""}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{log.actor.displayName}
|
||||
{actorRole && ` (${actorRole})`}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(log.timestamp).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<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 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.data && Object.keys(log.data).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.data} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [actorFilter, setActorFilter] = useState<AuditActorType | null>(null);
|
||||
const { t } = useLocale();
|
||||
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 matchesActor = !actorFilter || log.actor.type === actorFilter;
|
||||
|
||||
return matchesSearch && matchesActor;
|
||||
});
|
||||
|
||||
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[];
|
||||
|
||||
const actorOptions = uniqueActorTypes.map((actorType) => ({
|
||||
label: actorType,
|
||||
value: actorType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BookingLogsFilters
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
actorFilter={actorFilter}
|
||||
onActorFilterChange={setActorFilter}
|
||||
actorOptions={actorOptions}
|
||||
/>
|
||||
|
||||
<BookingLogsTimeline logs={filteredLogs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DataTableFilters } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
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";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { getWeekStart } from "../lib/weekUtils";
|
||||
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
|
||||
import { BookingCalendarView } from "./BookingCalendarView";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
import { WeekPicker } from "./WeekPicker";
|
||||
|
||||
// For calendar view, fetch all statuses except cancelled
|
||||
const STATUSES: BookingListingStatus[] = ["upcoming", "unconfirmed", "recurring", "past"];
|
||||
|
||||
interface BookingCalendarContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
bookingsV3Enabled: boolean;
|
||||
}
|
||||
|
||||
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
data?: {
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
recurringInfo: BookingsGetOutput["recurringInfo"];
|
||||
totalCount: BookingsGetOutput["totalCount"];
|
||||
};
|
||||
allowedFilterIds: string[];
|
||||
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"));
|
||||
};
|
||||
|
||||
const goToNextWeek = () => {
|
||||
setCurrentWeekStart(currentWeekStart.add(1, "week"));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentWeekStart(getWeekStart(dayjs(), userWeekStart));
|
||||
};
|
||||
|
||||
const ErrorView = errorMessage ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
|
||||
) : undefined;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status }).filter((column) =>
|
||||
allowedFilterIds.includes(column.id || "")
|
||||
);
|
||||
}, [allowedFilterIds, t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<WeekPicker
|
||||
currentWeekStart={currentWeekStart}
|
||||
userWeekStart={userWeekStart}
|
||||
onDateChange={setCurrentWeekStart}
|
||||
/>
|
||||
{allowedFilterIds.length > 0 && <DataTableFilters.FilterBar table={table} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize">
|
||||
{t("today")}
|
||||
</Button>
|
||||
<ButtonGroup combined>
|
||||
<Button color="secondary" onClick={goToPreviousWeek}>
|
||||
<span className="sr-only">{t("view_previous_week")}</span>
|
||||
<Icon name="chevron-left" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button color="secondary" onClick={goToNextWeek}>
|
||||
<span className="sr-only">{t("view_next_week")}</span>
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
|
||||
</div>
|
||||
</div>
|
||||
{hasError && ErrorView ? (
|
||||
ErrorView
|
||||
) : (
|
||||
<BookingCalendarView
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={setCurrentWeekStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
const { canReadOthersBookings } = props.permissions;
|
||||
const { userIds } = useBookingFilters();
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const allowedFilterIds = useCalendarAllowedFilters({
|
||||
canReadOthersBookings,
|
||||
});
|
||||
|
||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||
{
|
||||
limit: 100, // Use max limit for calendar view
|
||||
filters: {
|
||||
statuses: STATUSES,
|
||||
userIds,
|
||||
// Always fetch only the current week for calendar view
|
||||
afterStartDate: currentWeekStart.startOf("day").toISOString(),
|
||||
beforeEndDate: currentWeekStart.add(6, "day").endOf("day").toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
|
||||
const { isFetched, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
|
||||
// Automatically fetch all pages until no more data
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten all pages into a single data object
|
||||
const data = useMemo(() => {
|
||||
if (!query.data?.pages) return undefined;
|
||||
|
||||
// Combine all bookings from all pages
|
||||
const allBookings = query.data.pages.flatMap((page) => page.bookings);
|
||||
|
||||
// recurringInfo is the same across all pages (queried globally by user, not per page)
|
||||
const recurringInfo = query.data.pages[0]?.recurringInfo ?? [];
|
||||
|
||||
// totalCount is the same across all pages
|
||||
const totalCount = query.data.pages[0]?.totalCount ?? 0;
|
||||
|
||||
return { bookings: allBookings, recurringInfo, totalCount };
|
||||
}, [query.data?.pages]);
|
||||
|
||||
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} capabilities={capabilities}>
|
||||
<BookingCalendarInner
|
||||
{...props}
|
||||
data={data}
|
||||
allowedFilterIds={allowedFilterIds}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetched={isFetched}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
+18
-73
@@ -6,44 +6,28 @@ import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useBanners } from "@calcom/features/shell/banners/useBanners";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
type BookingsCalendarViewProps = {
|
||||
type BookingCalendarViewProps = {
|
||||
bookings: BookingOutput[];
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export function BookingsCalendarView({
|
||||
export function BookingCalendarView({
|
||||
bookings,
|
||||
currentWeekStart,
|
||||
onWeekStartChange,
|
||||
isPending = false,
|
||||
}: BookingsCalendarViewProps) {
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
const { t } = useLocale();
|
||||
}: BookingCalendarViewProps) {
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
||||
const { timezone } = useTimePreferences();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
|
||||
const goToPreviousWeek = () => {
|
||||
onWeekStartChange(currentWeekStart.subtract(1, "week"));
|
||||
};
|
||||
|
||||
const goToNextWeek = () => {
|
||||
onWeekStartChange(currentWeekStart.add(1, "week"));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
onWeekStartChange(dayjs().startOf("week"));
|
||||
};
|
||||
const { bannersHeight } = useBanners();
|
||||
|
||||
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
|
||||
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
|
||||
@@ -84,58 +68,17 @@ export function BookingsCalendarView({
|
||||
options: {
|
||||
status: booking.status,
|
||||
...(eventTypeColor && { color: eventTypeColor }),
|
||||
bookingId: booking.id,
|
||||
bookingUid: booking.uid,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
|
||||
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
const startMonth = weekStart.format("MMM");
|
||||
const endMonth = weekEnd.format("MMM");
|
||||
const year = weekEnd.format("YYYY");
|
||||
|
||||
const weekRange =
|
||||
startMonth === endMonth ? (
|
||||
<>
|
||||
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
|
||||
"D"
|
||||
)}`}</span>
|
||||
<span className="text-muted">, {year}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
|
||||
<span className="text-muted">, {year}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
|
||||
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">{weekRange}</h2>
|
||||
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
|
||||
{t("today")}
|
||||
</Button>
|
||||
<ButtonGroup combined>
|
||||
<Button color="secondary" onClick={goToPreviousWeek}>
|
||||
<span className="sr-only">{t("view_previous_week")}</span>
|
||||
<Icon name="chevron-left" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button color="secondary" onClick={goToNextWeek}>
|
||||
<span className="sr-only">{t("view_next_week")}</span>
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
|
||||
<>
|
||||
<div
|
||||
className="border-subtle flex flex-1 flex-col overflow-y-auto overflow-x-hidden rounded-2xl border"
|
||||
style={{ height: `calc(100vh - 6rem - ${bannersHeight}px)` }}>
|
||||
<Calendar
|
||||
timezone={timezone}
|
||||
sortEvents
|
||||
@@ -149,15 +92,17 @@ export function BookingsCalendarView({
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
selectedBookingUid={selectedBookingUid}
|
||||
onEventClick={(event) => {
|
||||
const bookingId = event.options?.bookingId;
|
||||
if (bookingId) {
|
||||
setSelectedBookingId(bookingId);
|
||||
const bookingUid = event.options?.bookingUid;
|
||||
if (bookingUid) {
|
||||
setSelectedBookingUid(bookingUid);
|
||||
}
|
||||
}}
|
||||
showTimezone
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -8,9 +9,16 @@ import { useBookingLocation } from "@calcom/features/bookings/hooks";
|
||||
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
|
||||
import { formatPrice } from "@calcom/lib/currencyConversions";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import {
|
||||
bookingMetadataSchema,
|
||||
eventTypeBookingFields,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -26,13 +34,15 @@ import {
|
||||
SheetTitle,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
|
||||
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import {
|
||||
useBookingDetailsSheetStore,
|
||||
useBookingDetailsSheetStoreApi,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
@@ -86,35 +96,55 @@ function BookingDetailsSheetInner({
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Get navigation state directly from the store
|
||||
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
|
||||
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
// Fetch additional booking details for reschedule information
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// 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 = () => {
|
||||
setSelectedBookingId(null);
|
||||
navigation.setSelectedBookingUid(null);
|
||||
};
|
||||
|
||||
const storeApi = useBookingDetailsSheetStoreApi();
|
||||
|
||||
const handleNext = () => {
|
||||
const nextId = storeApi.getState().getNextBookingId();
|
||||
if (nextId !== null) {
|
||||
setSelectedBookingId(nextId);
|
||||
}
|
||||
navigation.navigateNext();
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const prevId = storeApi.getState().getPreviousBookingId();
|
||||
if (prevId !== null) {
|
||||
setSelectedBookingId(prevId);
|
||||
}
|
||||
navigation.navigatePrevious();
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
const endTime = dayjs(booking.endTime).tz(userTimeZone);
|
||||
|
||||
const getStatusBadge = () => {
|
||||
const statusBadge = useMemo(() => {
|
||||
if (booking.rescheduled) {
|
||||
return {
|
||||
variant: "red" as const,
|
||||
label: t("rescheduled"),
|
||||
};
|
||||
}
|
||||
|
||||
switch (booking.status) {
|
||||
case "ACCEPTED":
|
||||
return { variant: "green" as const, label: t("confirmed") };
|
||||
@@ -127,9 +157,9 @@ function BookingDetailsSheetInner({
|
||||
default:
|
||||
return { variant: "gray" as const, label: booking.status };
|
||||
}
|
||||
};
|
||||
}, [booking.status, booking.rescheduled, t]);
|
||||
|
||||
const statusBadge = getStatusBadge();
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
@@ -148,18 +178,43 @@ function BookingDetailsSheetInner({
|
||||
.map(([question, answer]) => [question, answer] as [string, unknown])
|
||||
: [];
|
||||
|
||||
const reason = booking.assignmentReason?.[0];
|
||||
const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum);
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleClose}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader
|
||||
showCloseButton={false}
|
||||
rightContent={
|
||||
<Sheet open={true} onOpenChange={handleClose} modal={false}>
|
||||
<SheetContent
|
||||
className="overflow-y-auto"
|
||||
hideOverlay
|
||||
onInteractOutside={(e) => {
|
||||
// Check if the click is on a booking list item
|
||||
const target = e.target as HTMLElement;
|
||||
const isBookingListItem = target.closest("[data-booking-list-item]");
|
||||
|
||||
if (isBookingListItem) {
|
||||
// Prevent closing when clicking a booking list item
|
||||
// The item's onClick will handle opening the sheet with the new booking
|
||||
e.preventDefault();
|
||||
}
|
||||
// If clicking elsewhere, allow the default behavior (close the sheet)
|
||||
}}>
|
||||
<SheetHeader showCloseButton={false} className="mt-0 w-full">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<div className="flex min-w-0 flex-col gap-y-1">
|
||||
<BookingHeaderBadges
|
||||
statusBadge={statusBadge}
|
||||
reasonTitle={reasonTitle}
|
||||
booking={booking}
|
||||
recurringInfo={recurringInfo}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!hasPrevious}
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
@@ -167,41 +222,54 @@ function BookingDetailsSheetInner({
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!hasNext}
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusBadge.variant} className="capitalize">
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{recurringInfo && (
|
||||
<Badge variant="gray">
|
||||
<Icon name="repeat" className="mr-1 h-3 w-3" />
|
||||
{recurringInfo.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="stack-y-6">
|
||||
<div className="stack-y-6">
|
||||
<div className="stack-y-1">
|
||||
<SheetTitle className="text-2xl font-semibold">{booking.title}</SheetTitle>
|
||||
<p className="text-subtle text-sm">
|
||||
{startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
|
||||
{userTimeZone || startTime.format("Z")})
|
||||
</p>
|
||||
<SheetBody className="-mt-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
|
||||
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
|
||||
<span>{booking.title}</span>
|
||||
</SheetTitle>
|
||||
</div>
|
||||
|
||||
<RescheduleRequestMessage booking={booking} />
|
||||
<WhenSection
|
||||
rescheduled={booking.rescheduled || false}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
timeZone={userTimeZone}
|
||||
previousBooking={bookingDetails?.previousBooking}
|
||||
/>
|
||||
|
||||
<OldRescheduledBookingInfo
|
||||
booking={booking}
|
||||
rescheduledToBooking={bookingDetails?.rescheduledToBooking}
|
||||
/>
|
||||
|
||||
<NewRescheduledBookingInfo booking={booking} />
|
||||
|
||||
<CancelledBookingInfo booking={booking} />
|
||||
|
||||
<WhoSection booking={booking} />
|
||||
|
||||
@@ -209,49 +277,66 @@ function BookingDetailsSheetInner({
|
||||
|
||||
<RecurringInfoSection recurringInfo={recurringInfo} />
|
||||
|
||||
<PaymentSection booking={booking} />
|
||||
<AssignmentReasonSection booking={booking} />
|
||||
|
||||
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
|
||||
|
||||
<SlotsSection booking={booking} />
|
||||
|
||||
<AdditionalNotesSection booking={booking} />
|
||||
|
||||
<CustomQuestionsSection
|
||||
customResponses={customResponses}
|
||||
bookingFields={booking.eventType?.bookingFields}
|
||||
/>
|
||||
|
||||
<DescriptionSection booking={booking} />
|
||||
<TrackingSection tracking={bookingDetails?.tracking} />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
|
||||
<Button color="secondary" StartIcon="x" onClick={handleClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<div className="flex w-full min-w-0 flex-row flex-wrap items-center justify-end gap-2 px-4 pb-4 pt-4">
|
||||
{isPending ? (
|
||||
<>
|
||||
<RejectBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={!!booking.recurringEventId}
|
||||
/>
|
||||
<AcceptBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={!!booking.recurringEventId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
!booking.rescheduled && (
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
context="details"
|
||||
/>
|
||||
</div>
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
context="details"
|
||||
/>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -259,17 +344,62 @@ function BookingDetailsSheetInner({
|
||||
);
|
||||
}
|
||||
|
||||
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
|
||||
function WhenSection({
|
||||
rescheduled,
|
||||
startTime,
|
||||
endTime,
|
||||
timeZone,
|
||||
previousBooking,
|
||||
}: {
|
||||
rescheduled: boolean;
|
||||
startTime: dayjs.Dayjs;
|
||||
endTime: dayjs.Dayjs;
|
||||
timeZone?: string;
|
||||
previousBooking?: { uid: string; startTime: Date; endTime: Date } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Section title={t("when")}>
|
||||
{previousBooking?.startTime && previousBooking?.endTime && (
|
||||
<div className="text-default flex flex-col text-sm line-through opacity-60">
|
||||
<DisplayTimestamp
|
||||
startTime={previousBooking.startTime}
|
||||
endTime={previousBooking.endTime}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis flex flex-col text-sm font-medium",
|
||||
rescheduled && "line-through"
|
||||
)}>
|
||||
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayTimestamp({
|
||||
startTime,
|
||||
endTime,
|
||||
timeZone,
|
||||
}: {
|
||||
startTime: Date | dayjs.Dayjs;
|
||||
endTime: Date | dayjs.Dayjs;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
|
||||
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
|
||||
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
<>
|
||||
<span>{start.format("dddd, MMMM D, YYYY")}</span>
|
||||
<span>
|
||||
{start.format("h:mma")} - {end.format("h:mma")} ({timeZone || start.format("Z")})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,35 +407,52 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Section title={t("who")}>
|
||||
<div className="stack-y-4">
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
{booking.user && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={getPlaceholderAvatar(null, booking.user.name)}
|
||||
alt={booking.user.name || ""}
|
||||
imageSrc={
|
||||
booking.user.avatarUrl
|
||||
? getUserAvatarUrl(booking.user)
|
||||
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
|
||||
}
|
||||
alt={booking.user.name || booking.user.email || ""}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{booking.user.name}</p>
|
||||
<Badge variant="blue" size="sm">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">
|
||||
{booking.user.name || booking.user.email}
|
||||
</p>
|
||||
<Badge variant="purple" size="sm" className="capitalize">
|
||||
{t("host")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-default truncate text-sm">{booking.user.email}</p>
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.map((attendee, idx) => (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar size="md" imageSrc={getPlaceholderAvatar(null, attendee.name)} alt={attendee.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{attendee.name}</p>
|
||||
<p className="text-default truncate text-sm">{attendee.email}</p>
|
||||
{booking.attendees.map((attendee, idx) => {
|
||||
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={
|
||||
attendee.user?.avatarUrl
|
||||
? getUserAvatarUrl(attendee.user)
|
||||
: getPlaceholderAvatar(null, name)
|
||||
}
|
||||
alt={name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">{name}</p>
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -321,6 +468,10 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
bookingStatus: booking.status,
|
||||
});
|
||||
|
||||
if (booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!locationToDisplay) {
|
||||
return null;
|
||||
}
|
||||
@@ -345,7 +496,7 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<span className="text-default shrink-0">{provider?.label}:</span>
|
||||
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
@@ -372,7 +523,7 @@ function RecurringInfoSection({
|
||||
|
||||
return (
|
||||
<Section title={t("recurring_event")}>
|
||||
<p className="text-default text-sm">
|
||||
<p className="text-emphasis text-sm font-medium">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: recurringInfo.recurringEvent,
|
||||
@@ -383,19 +534,60 @@ function RecurringInfoSection({
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentSection({ booking }: { booking: BookingOutput }) {
|
||||
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
|
||||
if (!booking.assignmentReason || booking.assignmentReason.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payment = booking.payment[0];
|
||||
// we fetch only one assignment reason.
|
||||
const reason = booking.assignmentReason[0];
|
||||
if (!reason.reasonString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("assignment_reason")}>
|
||||
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentSection({
|
||||
booking,
|
||||
payment,
|
||||
}: {
|
||||
booking: BookingOutput;
|
||||
payment: NonNullable<BookingOutput["payment"]>[number];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Get refund policy from event type metadata
|
||||
const parsedEventTypeMetadata = booking.eventType?.metadata
|
||||
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
|
||||
: null;
|
||||
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
|
||||
|
||||
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
|
||||
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
|
||||
|
||||
const paymentStatusMessage = usePaymentStatus({
|
||||
bookingStatus: booking.status,
|
||||
startTime: booking.startTime,
|
||||
eventTypeTeamId: booking.eventType?.teamId,
|
||||
userId: booking.user?.id,
|
||||
payment,
|
||||
refundPolicy,
|
||||
refundDaysCount,
|
||||
});
|
||||
|
||||
const formattedPrice = formatPrice(payment.amount, payment.currency);
|
||||
|
||||
return (
|
||||
<Section title={t("payment")}>
|
||||
<p className="text-default text-sm">{formattedPrice}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
|
||||
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -416,7 +608,7 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
|
||||
|
||||
return (
|
||||
<Section title={t("slots")}>
|
||||
<p className="text-default text-sm">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -470,7 +662,7 @@ function CustomQuestionsSection({
|
||||
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
|
||||
return (
|
||||
<Section key={idx} title={title}>
|
||||
<p className="text-default text-sm">{String(answer)}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
@@ -478,7 +670,124 @@ function CustomQuestionsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionSection({ booking }: { booking: BookingOutput }) {
|
||||
// Old booking that was rescheduled away
|
||||
function OldRescheduledBookingInfo({
|
||||
booking,
|
||||
rescheduledToBooking,
|
||||
}: {
|
||||
booking: BookingOutput;
|
||||
rescheduledToBooking?: { uid: string } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for old rescheduled bookings
|
||||
if (!booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduledBy;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
return (
|
||||
<>
|
||||
{rescheduledToBooking?.uid && (
|
||||
<Section title={t("rescheduled")}>
|
||||
<Link href={`/booking/${rescheduledToBooking.uid}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
{t("view_booking")}
|
||||
<Icon name="external-link" className="h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</Section>
|
||||
)}
|
||||
{rescheduledBy && (
|
||||
<Section title={t("rescheduled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// New booking that replaced an old one
|
||||
function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for new bookings that replaced an old one
|
||||
if (!booking.fromReschedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduler;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t("rescheduled_by")}>
|
||||
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
|
||||
<Link href={`/booking/${booking.fromReschedule}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
{t("original_booking")}
|
||||
<Icon name="external-link" className="h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</Section>
|
||||
{cancellationReason && (
|
||||
<Section title={t("reschedule_reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Purely cancelled or rejected (not rescheduled)
|
||||
function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for cancelled/rejected bookings that were NOT rescheduled
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
const wasRescheduled = booking.rescheduled === true;
|
||||
|
||||
if (!isCancelled || wasRescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
if (!cancellationReason && !cancelledBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.description) {
|
||||
@@ -486,8 +795,87 @@ function DescriptionSection({ booking }: { booking: BookingOutput }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("description")}>
|
||||
<p className="text-default whitespace-pre-wrap text-sm">{booking.description}</p>
|
||||
<Section title={t("additional_notes")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingHeaderBadges({
|
||||
statusBadge,
|
||||
reasonTitle,
|
||||
booking,
|
||||
recurringInfo,
|
||||
}: {
|
||||
statusBadge: { variant: "red" | "green" | "orange" | "gray"; label: string };
|
||||
reasonTitle: string | undefined;
|
||||
booking: BookingOutput;
|
||||
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const payment = booking.payment?.[0];
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant={statusBadge.variant} className="capitalize">
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
{reasonTitle && (
|
||||
<Badge variant="gray" className="capitalize">
|
||||
{reasonTitle}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{booking.paid && !payment ? (
|
||||
<Badge variant="orange">{t("error_collecting_card")}</Badge>
|
||||
) : booking.paid ? (
|
||||
<Badge variant="green" data-testid="paid_badge">
|
||||
{payment?.paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{recurringInfo && (
|
||||
<Badge variant="gray">
|
||||
<Icon name="repeat" className="mr-1 h-3 w-3" />
|
||||
{recurringInfo.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -502,8 +890,8 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("stack-y-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-semibold">{title}</h3>
|
||||
<div className={classNames("flex flex-col gap-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-medium">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-16
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
|
||||
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
|
||||
import { DataTableWrapper } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
@@ -18,7 +18,7 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
unconfirmed: "unconfirmed_bookings",
|
||||
};
|
||||
|
||||
type BookingsListViewProps = {
|
||||
type BookingListViewProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending: boolean;
|
||||
@@ -27,14 +27,14 @@ type BookingsListViewProps = {
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
export function BookingsList({
|
||||
export function BookingList({
|
||||
status,
|
||||
table,
|
||||
isPending,
|
||||
totalRowCount,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsListViewProps) {
|
||||
}: BookingListViewProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -48,18 +48,7 @@ export function BookingsList({
|
||||
totalRowCount={totalRowCount}
|
||||
variant="compact"
|
||||
paginationMode="standard"
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
</>
|
||||
}
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</>
|
||||
}
|
||||
separatorClassName="py-4 pl-6 text-xs uppercase leading-4"
|
||||
LoaderView={<SkeletonLoader />}
|
||||
EmptyView={
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useDataTable,
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDisplayedFilterCount,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
|
||||
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
|
||||
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
|
||||
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
|
||||
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
useBookingDetailsSheetStore,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingList } from "./BookingList";
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
interface FilterButtonProps {
|
||||
table: ReturnType<typeof useReactTable<RowData>>;
|
||||
displayedFilterCount: number;
|
||||
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
}
|
||||
|
||||
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (displayedFilterCount === 0) {
|
||||
return <DataTableFilters.AddFilterButton table={table} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon="list-filter"
|
||||
className="h-full"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters((value) => !value)}>
|
||||
{t("filter")}
|
||||
<Badge variant="gray" className="ml-1">
|
||||
{displayedFilterCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookingListContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
bookingsV3Enabled: boolean;
|
||||
}
|
||||
|
||||
interface BookingListInnerProps extends BookingListContainerProps {
|
||||
data?: BookingsGetOutput;
|
||||
isPending: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
totalRowCount?: number;
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
}
|
||||
|
||||
function BookingListInner({
|
||||
status,
|
||||
permissions,
|
||||
bookings,
|
||||
bookingsV3Enabled,
|
||||
data,
|
||||
isPending,
|
||||
hasError,
|
||||
errorMessage,
|
||||
totalRowCount,
|
||||
}: BookingListInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
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;
|
||||
|
||||
const handleBookingClick = useCallback(
|
||||
(bookingUid: string) => {
|
||||
setSelectedBookingUid(bookingUid);
|
||||
},
|
||||
[setSelectedBookingUid]
|
||||
);
|
||||
|
||||
const columns = useBookingListColumns({
|
||||
user,
|
||||
status,
|
||||
canReadOthersBookings: permissions.canReadOthersBookings,
|
||||
bookingsV3Enabled,
|
||||
handleBookingClick,
|
||||
});
|
||||
|
||||
const finalData = useBookingListData({ data, status, userTimeZone: user?.timeZone });
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const displayedFilterCount = useDisplayedFilterCount();
|
||||
const { currentTab, tabOptions } = useBookingStatusTab();
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedFilterCount === 0) {
|
||||
// reset to true, so it shows filters as soon as any filter is applied
|
||||
setShowFilters(true);
|
||||
}
|
||||
}, [displayedFilterCount]);
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
const isEmpty = !data?.bookings || data.bookings.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Desktop: full width on first row, Mobile: full width on first row with horizontal scroll */}
|
||||
<div className="w-full md:w-auto">
|
||||
<div className="overflow-x-auto md:overflow-visible">
|
||||
<ToggleGroup
|
||||
value={currentTab}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const selectedTab = tabOptions.find((tab) => tab.value === value);
|
||||
if (selectedTab?.href) {
|
||||
router.push(selectedTab.href);
|
||||
}
|
||||
}}
|
||||
options={tabOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: second item on first row, Mobile: first item on second row */}
|
||||
<FilterButton
|
||||
table={table}
|
||||
displayedFilterCount={displayedFilterCount}
|
||||
setShowFilters={setShowFilters}
|
||||
/>
|
||||
|
||||
{/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
|
||||
<div className="hidden grow md:block" />
|
||||
|
||||
<DataTableSegment.Select shortLabel />
|
||||
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
|
||||
</div>
|
||||
{displayedFilterCount > 0 && showFilters && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} variant="minimal" />
|
||||
|
||||
{/* Desktop: auto-pushed to right via flex-grow spacer */}
|
||||
<div className="hidden flex-grow md:block" />
|
||||
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
</div>
|
||||
)}
|
||||
{status === "upcoming" && !isEmpty && (
|
||||
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<BookingList
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bookingsV3Enabled && (
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset, setPageIndex } = useDataTable();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
// Build query input once - shared between query and prefetching
|
||||
const queryInput = useMemo(
|
||||
() => ({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
statuses: [props.status],
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
}),
|
||||
[
|
||||
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} capabilities={capabilities}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
data={query.data}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
totalRowCount={query.data?.totalCount}
|
||||
bookings={bookings}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDataTable,
|
||||
ColumnFilterType,
|
||||
} from "@calcom/features/data-table";
|
||||
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
|
||||
import { BookingsCalendarView } from "./BookingsCalendarView";
|
||||
|
||||
type BookingsCalendarProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending?: boolean;
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
setCurrentWeekStart: (
|
||||
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
|
||||
) => Promise<URLSearchParams>;
|
||||
bookings: BookingOutput[];
|
||||
ErrorView?: React.ReactNode;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
const COLUMN_IDS_TO_HIDE = ["dateRange"];
|
||||
|
||||
export function BookingsCalendar({
|
||||
table,
|
||||
isPending = false,
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
bookings,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsCalendarProps) {
|
||||
const { updateFilter } = useDataTable();
|
||||
|
||||
const handleWeekStartChange = useCallback(
|
||||
(newWeekStart: dayjs.Dayjs) => {
|
||||
setCurrentWeekStart(newWeekStart);
|
||||
|
||||
// Always set the date range to match the current week exactly
|
||||
const startDate = newWeekStart.toDate();
|
||||
const endDate = newWeekStart.add(6, "day").toDate();
|
||||
|
||||
updateFilter("dateRange", {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: {
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
preset: CUSTOM_PRESET.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateFilter, setCurrentWeekStart]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</div>
|
||||
</div>
|
||||
{hasError && ErrorView ? (
|
||||
ErrorView
|
||||
) : (
|
||||
<BookingsCalendarView
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={handleWeekStartChange}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsCalendar } from "./BookingsCalendar";
|
||||
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
interface BookingsCalendarContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data?: BookingsGetOutput;
|
||||
isPending?: boolean;
|
||||
ErrorView?: React.ReactNode;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function BookingsCalendarContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending = false,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsCalendarContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status });
|
||||
}, [t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
/**
|
||||
* Transform raw booking data into RowData format for the calendar view
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - Attaches recurring info and isToday flag to each booking
|
||||
*/
|
||||
const rowData = useMemo<RowData[]>(() => {
|
||||
if (!data?.bookings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
|
||||
// Key: recurringEventId, Value: array of all bookings in that series
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
// Deduplicate recurring bookings for specific status tabs
|
||||
// This ensures we show only ONE booking per recurring series instead of all occurrences
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
// Non-recurring bookings are always shown
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've already encountered this recurring series
|
||||
if (
|
||||
shownBookings[booking.recurringEventId] !== undefined &&
|
||||
shownBookings[booking.recurringEventId].length > 0
|
||||
) {
|
||||
// Store this occurrence but DON'T display it (return false to filter out)
|
||||
shownBookings[booking.recurringEventId].push(booking);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First occurrence of this recurring series - show it and start tracking
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return data.bookings.filter(filterBookings).map((booking) => {
|
||||
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
|
||||
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
|
||||
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
|
||||
|
||||
return {
|
||||
type: "data" as const,
|
||||
booking,
|
||||
isToday: bookingDateStr === today,
|
||||
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
};
|
||||
});
|
||||
}, [data, status, user?.timeZone]);
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data and filter by current week
|
||||
const bookings = useMemo(() => {
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking)
|
||||
.filter((booking) => {
|
||||
const bookingStart = dayjs(booking.startTime);
|
||||
return (
|
||||
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
|
||||
bookingStart.isBefore(weekEnd.endOf("day"))
|
||||
);
|
||||
});
|
||||
}, [rowData, currentWeekStart]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingsCalendar
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
currentWeekStart={currentWeekStart}
|
||||
setCurrentWeekStart={setCurrentWeekStart}
|
||||
bookings={bookings}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
useBookingDetailsSheetStore,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsList } from "./BookingsList";
|
||||
|
||||
interface BookingsListContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data?: BookingsGetOutput;
|
||||
isPending: boolean;
|
||||
enableDetailsSheet: boolean;
|
||||
totalRowCount?: number;
|
||||
ErrorView?: React.ReactNode;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function BookingsListInner({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending,
|
||||
enableDetailsSheet,
|
||||
totalRowCount,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsListContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
|
||||
const handleBookingClick = useCallback(
|
||||
(bookingId: number) => {
|
||||
setSelectedBookingId(bookingId);
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
// Define table columns for filtering (hidden columns used for filter UI)
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
if (props.row.original.type === "data") {
|
||||
const { booking, recurringInfo, isToday } = props.row.original;
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
isToday={isToday}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...(enableDetailsSheet && { onClick: () => handleBookingClick(booking.id) })}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
} else if (props.row.original.type === "today") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("today")}
|
||||
</p>
|
||||
);
|
||||
} else if (props.row.original.type === "next") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("next")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t, permissions.canReadOthersBookings, enableDetailsSheet, handleBookingClick]);
|
||||
|
||||
/**
|
||||
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
|
||||
*/
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
|
||||
// Key: recurringEventId, Value: array of all bookings in that series
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
// Deduplicate recurring bookings for specific status tabs
|
||||
// This ensures we show only ONE booking per recurring series instead of all occurrences
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
// Non-recurring bookings are always shown
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've already encountered this recurring series
|
||||
if (
|
||||
shownBookings[booking.recurringEventId] !== undefined &&
|
||||
shownBookings[booking.recurringEventId].length > 0
|
||||
) {
|
||||
// Store this occurrence but DON'T display it (return false to filter out)
|
||||
shownBookings[booking.recurringEventId].push(booking);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First occurrence of this recurring series - show it and start tracking
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
|
||||
return (
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data",
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [data, status, user?.timeZone]);
|
||||
|
||||
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return (data?.bookings ?? [])
|
||||
.filter(
|
||||
(booking: BookingOutput) =>
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
)
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
isToday: true,
|
||||
}));
|
||||
}, [data, user?.timeZone]);
|
||||
|
||||
// Combine data with section separators for "upcoming" tab
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
// For other statuses, just return the flat list
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
|
||||
// For "upcoming" status, organize into "Today" and "Next" sections
|
||||
const merged: RowData[] = [];
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "today" as const }, ...bookingsToday);
|
||||
}
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "next" as const }, ...flatData);
|
||||
}
|
||||
return merged;
|
||||
}, [bookingsToday, flatData, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookingsList
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
|
||||
{enableDetailsSheet && (
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingsListContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending,
|
||||
enableDetailsSheet,
|
||||
totalRowCount,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsListContainerProps) {
|
||||
// Extract bookings from data for BookingDetailsSheet
|
||||
const bookings = useMemo(() => data?.bookings ?? [], [data]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingsListInner
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={data}
|
||||
isPending={isPending}
|
||||
enableDetailsSheet={enableDetailsSheet}
|
||||
totalRowCount={totalRowCount}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user