Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
aedc0f5a03 | ||
|
|
ff88ec0eae | ||
|
|
110b5ceaf6 | ||
|
|
7c53d2c9ce | ||
|
|
eccebff4c6 | ||
|
|
0a36dd7f61 | ||
|
|
5143083efb | ||
|
|
b874e0d55a | ||
|
|
3ee24586a7 | ||
|
|
b014ded5fd |
@@ -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: []
|
||||
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -10,7 +10,7 @@ ARG CALCOM_TELEMETRY_DISABLED
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET=secret
|
||||
ARG CALENDSO_ENCRYPTION_KEY=secret
|
||||
ARG MAX_OLD_SPACE_SIZE=4096
|
||||
ARG MAX_OLD_SPACE_SIZE=6144
|
||||
ARG NEXT_PUBLIC_API_V2_URL
|
||||
ARG CSP_POLICY
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+164
-2
@@ -34,6 +34,7 @@ import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
BaseConfirmationPolicy_2024_06_14,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
supportedIntegrations,
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
@@ -640,9 +641,9 @@ describe("Event types Endpoints", () => {
|
||||
hiddenEventType = responseBody.data;
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username`, async () => {
|
||||
it(`/GET/event-types by username with sortCreatedAt=desc`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=desc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
@@ -653,6 +654,12 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Verify ordering: event types are returned newest to oldest when sortCreatedAt=desc
|
||||
// hiddenEventType was created after eventType, so it should have a higher ID and appear first
|
||||
expect(responseBody.data[0].id).toBeGreaterThan(responseBody.data[1].id);
|
||||
expect(responseBody.data[0].id).toEqual(hiddenEventType.id);
|
||||
expect(responseBody.data[1].id).toEqual(eventType.id);
|
||||
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
|
||||
|
||||
@@ -711,6 +718,46 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username with sortCreatedAt=asc`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=asc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Verify ordering: event types are returned oldest to newest when sortCreatedAt=asc
|
||||
// eventType was created before hiddenEventType, so it should have a lower ID and appear first
|
||||
expect(responseBody.data[0].id).toBeLessThan(responseBody.data[1].id);
|
||||
expect(responseBody.data[0].id).toEqual(eventType.id);
|
||||
expect(responseBody.data[1].id).toEqual(hiddenEventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username without sortCreatedAt parameter`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Without sortCreatedAt, no specific order is guaranteed
|
||||
// Just verify both event types are present
|
||||
const ids = responseBody.data.map((et) => et.id);
|
||||
expect(ids).toContain(eventType.id);
|
||||
expect(ids).toContain(hiddenEventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
@@ -1439,6 +1486,7 @@ describe("Event types Endpoints", () => {
|
||||
locations: [],
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: team.id } },
|
||||
rrHostSubsetEnabled: true,
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -1451,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);
|
||||
});
|
||||
});
|
||||
@@ -2855,6 +2904,119 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept newly supported integration locations in input validation", async () => {
|
||||
// Test that the API accepts various newly supported integrations by creating event types
|
||||
// with these location types directly in the database and then retrieving them
|
||||
// Note: Database uses underscores in integration types, API returns hyphens
|
||||
const integrationTests = [
|
||||
{ internal: "integrations:jitsi", api: "jitsi" },
|
||||
{ internal: "integrations:whereby_video", api: "whereby-video" },
|
||||
{ internal: "integrations:huddle01", api: "huddle" },
|
||||
{ internal: "integrations:tandem", api: "tandem" },
|
||||
{ internal: "integrations:element-call_video", api: "element-call-video" },
|
||||
];
|
||||
|
||||
for (const { internal, api } of integrationTests) {
|
||||
const eventTypeInput = {
|
||||
title: `event type ${api}`,
|
||||
description: "event type description",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
slug: `event-type-${api}`,
|
||||
locations: [
|
||||
{
|
||||
type: internal,
|
||||
link: `https://example.com/${api}/meeting`,
|
||||
},
|
||||
],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
bookingFields: [],
|
||||
};
|
||||
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${legacyEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
// Verify the integration location is returned correctly
|
||||
expect(fetchedEventType.locations).toHaveLength(1);
|
||||
expect(fetchedEventType.locations[0]).toMatchObject({
|
||||
type: "integration",
|
||||
integration: api,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await eventTypesRepositoryFixture.delete(legacyEventType.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject with 400 if creating event type with unsupported integration location", async () => {
|
||||
const createPayload = {
|
||||
title: "Event with unsupported integration",
|
||||
slug: "event-with-unsupported-integration",
|
||||
lengthInMinutes: 30,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "unsupported-integration",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Validation failed for integration location: integration must be one of the following values: ${supportedIntegrations.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if patching event type with integration that user has not connected", async () => {
|
||||
const createPayload = {
|
||||
title: "Event for patch test",
|
||||
slug: "event-patch-test-unconnected-integration",
|
||||
lengthInMinutes: 30,
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
const patchPayload = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "jitsi",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patchResponse = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(patchPayload);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body.error.message).toBe("jitsi not connected.");
|
||||
|
||||
// Cleanup
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
|
||||
describe("EventType Hidden Property", () => {
|
||||
let createdEventTypeId: number;
|
||||
|
||||
|
||||
+2
@@ -155,6 +155,8 @@ export class EventTypesController_2024_06_14 {
|
||||
summary: "Get all event types",
|
||||
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
|
||||
|
||||
Use the optional \`sortCreatedAt\` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.
|
||||
|
||||
<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>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -67,21 +68,23 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
hidden: false,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
+11
-9
@@ -22,7 +22,7 @@ import {
|
||||
getEventTypesPublic,
|
||||
EventTypesPublic,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import type { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { GetEventTypesQuery_2024_06_14, SortOrderType } from "@calcom/platform-types";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -160,15 +160,16 @@ export class EventTypesService_2024_06_14 {
|
||||
orgSlug?: string;
|
||||
orgId?: number;
|
||||
authUser?: AuthOptionalUser;
|
||||
sortCreatedAt?: SortOrderType;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
if (params.authUser?.id !== user.id) {
|
||||
return await this.getUserEventTypesPublic(user.id);
|
||||
return await this.getUserEventTypesPublic(user.id, params.sortCreatedAt);
|
||||
}
|
||||
return await this.getUserEventTypes(user.id);
|
||||
return await this.getUserEventTypes(user.id, params.sortCreatedAt);
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
@@ -211,16 +212,16 @@ export class EventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
|
||||
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId, sortCreatedAt);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
|
||||
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId, sortCreatedAt);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
@@ -237,7 +238,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
|
||||
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
|
||||
const { username, eventSlug, usernames, orgSlug, orgId, sortCreatedAt } = queryParams;
|
||||
if (username && eventSlug) {
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug({
|
||||
username,
|
||||
@@ -255,6 +256,7 @@ export class EventTypesService_2024_06_14 {
|
||||
orgSlug,
|
||||
orgId,
|
||||
authUser,
|
||||
sortCreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,7 +266,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
if (authUser?.id) {
|
||||
return await this.getUserEventTypes(authUser.id);
|
||||
return await this.getUserEventTypes(authUser.id, sortCreatedAt);
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
+48
-8
@@ -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,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
InputBookingField_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
supportedIntegrations,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -128,6 +130,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
slug,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
@@ -143,6 +146,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const slugifiedSlug = slugify(slug);
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
@@ -152,6 +157,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
slug: slugifiedSlug,
|
||||
length: lengthInMinutes,
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
|
||||
@@ -208,6 +214,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
slug,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
@@ -228,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
|
||||
@@ -542,14 +556,40 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
|
||||
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||
const conferencingApps = supportedIntegrations as readonly string[];
|
||||
if (!conferencingApps.includes(appSlug)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||
}
|
||||
|
||||
if (appSlug === "office365-video") {
|
||||
appSlug = "msteams";
|
||||
}
|
||||
// Map API integration names to actual app slugs
|
||||
const slugMap: Record<string, string> = {
|
||||
"office365-video": "msteams",
|
||||
"facetime-video": "facetime",
|
||||
"whereby-video": "whereby",
|
||||
"whatsapp-video": "whatsapp",
|
||||
"webex-video": "webex",
|
||||
"telegram-video": "telegram",
|
||||
"sylaps-video": "sylapsvideo",
|
||||
"skype-video": "skype",
|
||||
"sirius-video": "sirius_video",
|
||||
"signal-video": "signal",
|
||||
"shimmer-video": "shimmervideo",
|
||||
"salesroom-video": "salesroom",
|
||||
"roam-video": "roam",
|
||||
"riverside-video": "riverside",
|
||||
"ping-video": "ping",
|
||||
"mirotalk-video": "mirotalk",
|
||||
"jelly-video": "jelly",
|
||||
"jelly-conferencing": "jelly",
|
||||
"huddle": "huddle01",
|
||||
"element-call-video": "element-call",
|
||||
"eightxeight-video": "eightxeight",
|
||||
"discord-video": "discord",
|
||||
"demodesk-video": "demodesk",
|
||||
"campfire-video": "campfire",
|
||||
};
|
||||
|
||||
appSlug = slugMap[appSlug] || appSlug;
|
||||
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
||||
|
||||
|
||||
@@ -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,14 @@
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
|
||||
constructor(taskerService: TaskerService) {
|
||||
super({
|
||||
tasker: taskerService.getTasker(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+17
-8
@@ -47,8 +47,8 @@ import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetOrganizationEventTypesQuery_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
SkipTakePagination,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -159,12 +159,16 @@ export class OrganizationsEventTypesController {
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
@ApiOperation({ summary: "Get team event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug, hostsLimit } = queryParams;
|
||||
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
|
||||
|
||||
if (eventSlug) {
|
||||
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(
|
||||
@@ -179,7 +183,7 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -191,16 +195,21 @@ export class OrganizationsEventTypesController {
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/event-types")
|
||||
@ApiOperation({ summary: "Get all team event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get all team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamsEventTypes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@Query() queryParams: GetOrganizationEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const { skip, take, sortCreatedAt } = queryParams;
|
||||
const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes(
|
||||
orgId,
|
||||
skip,
|
||||
take
|
||||
take,
|
||||
sortCreatedAt
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
+9
-1
@@ -1,16 +1,24 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsEventTypesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) {
|
||||
async getOrganizationTeamsEventTypes(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
sortCreatedAt?: SortOrderType
|
||||
) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
team: {
|
||||
parentId: orgId,
|
||||
},
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
skip,
|
||||
take,
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
|
||||
+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,
|
||||
|
||||
+12
-4
@@ -1,4 +1,6 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
|
||||
import {
|
||||
TransformedCreateTeamEventTypeInput,
|
||||
@@ -83,16 +85,22 @@ export class OrganizationsEventTypesService {
|
||||
return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug, hostsLimit);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesService.getTeamEventTypes(teamId);
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
}
|
||||
|
||||
async getOrganizationsTeamsEventTypes(
|
||||
orgId: number,
|
||||
skip = 0,
|
||||
take = 250
|
||||
take = 250,
|
||||
sortCreatedAt?: SortOrderType
|
||||
): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take);
|
||||
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(
|
||||
orgId,
|
||||
skip,
|
||||
take,
|
||||
sortCreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
async updateOrganizationTeamEventType(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -136,12 +136,16 @@ export class TeamsEventTypesController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get a team event type" })
|
||||
@ApiOperation({
|
||||
summary: "Get team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug, hostsLimit } = queryParams;
|
||||
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
|
||||
|
||||
if (eventSlug) {
|
||||
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(
|
||||
@@ -156,7 +160,7 @@ export class TeamsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId);
|
||||
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries/event-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -101,8 +103,8 @@ export class TeamsEventTypesService {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId);
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsEventTypesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -79,11 +81,12 @@ export class TeamsEventTypesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
|
||||
+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,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;
|
||||
@@ -64,10 +61,7 @@ const Page = async ({ params }: PageProps) => {
|
||||
: 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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,29 @@ 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 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";
|
||||
|
||||
@@ -146,36 +143,11 @@ 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,
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
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 +189,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 +221,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 +255,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 +264,236 @@ 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
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
data-booking-list-item="true"
|
||||
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
|
||||
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 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}
|
||||
/>
|
||||
)}
|
||||
{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 +501,7 @@ const BookingItemBadges = ({
|
||||
}: {
|
||||
booking: BookingItemProps;
|
||||
isPending: boolean;
|
||||
isRejected: boolean;
|
||||
recurringDates: Date[] | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
@@ -575,6 +523,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 +680,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 +703,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = user?.name || name || user?.email || email;
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -767,10 +714,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 +772,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 +789,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 +806,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 +827,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 +885,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 +905,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 +970,7 @@ const DisplayAttendees = ({
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
attendees: BookingAttendee[];
|
||||
user: UserProps | null;
|
||||
currentEmail?: string | null;
|
||||
bookingUid: string;
|
||||
@@ -1005,7 +980,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,340 @@
|
||||
/**
|
||||
* TODO: Move it to features/booking-audit
|
||||
*/
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
interface BookingLogsViewProps {
|
||||
bookingUid: string;
|
||||
}
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: Record<string, unknown> | null;
|
||||
actor: {
|
||||
type: string;
|
||||
displayName: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface BookingLogsFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
typeFilter: string | null;
|
||||
onTypeFilterChange: (value: string | null) => void;
|
||||
actorFilter: string | null;
|
||||
onActorFilterChange: (value: string | null) => void;
|
||||
typeOptions: Array<{ label: string; value: string }>;
|
||||
actorOptions: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
};
|
||||
|
||||
function BookingLogsFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
actorFilter,
|
||||
onActorFilterChange,
|
||||
typeOptions,
|
||||
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={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onTypeFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onActorFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</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 actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
|
||||
const showJson = showJsonMap[log.id] || false;
|
||||
|
||||
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">
|
||||
{actionDisplay}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
||||
<span>{log.actor.displayName}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(log.timestamp).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<div className="bg-muted rounded-[10px] py-1">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
||||
className="w-full justify-start text-xs font-medium text-subtle h-6">
|
||||
{isExpanded ? t("hide_details") : t("show_details")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 px-3">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
|
||||
<span className="text-default">{log.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">
|
||||
{t("timestamp")}
|
||||
</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleJson(log.id)}
|
||||
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
||||
className="mb-1 h-6 px-0 font-medium">
|
||||
{t("json")}
|
||||
</Button>
|
||||
{showJson && (
|
||||
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [actorFilter, setActorFilter] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
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 matchesType = !typeFilter || log.action === typeFilter;
|
||||
const matchesActor = !actorFilter || log.actor.type === actorFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesActor;
|
||||
});
|
||||
|
||||
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
|
||||
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
|
||||
|
||||
const typeOptions = uniqueTypes.map((type) => ({
|
||||
label: t(`audit_action.${type.toLowerCase()}`),
|
||||
value: type,
|
||||
}));
|
||||
|
||||
const actorOptions = uniqueActorTypes.map((actorType) => ({
|
||||
label: actorType,
|
||||
value: actorType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BookingLogsFilters
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
actorFilter={actorFilter}
|
||||
onActorFilterChange={setActorFilter}
|
||||
typeOptions={typeOptions}
|
||||
actorOptions={actorOptions}
|
||||
/>
|
||||
|
||||
<BookingLogsTimeline logs={filteredLogs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"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 { 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;
|
||||
};
|
||||
}
|
||||
|
||||
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
data?: {
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
recurringInfo: BookingsGetOutput["recurringInfo"];
|
||||
totalCount: BookingsGetOutput["totalCount"];
|
||||
};
|
||||
allowedFilterIds: string[];
|
||||
isPending: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function BookingCalendarInner({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
allowedFilterIds,
|
||||
hasError,
|
||||
errorMessage,
|
||||
}: BookingCalendarInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
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 rowData = useBookingCalendarData({ data, status });
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data
|
||||
const bookings = useMemo(() => {
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [rowData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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 />
|
||||
</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 } = 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
|
||||
}
|
||||
);
|
||||
|
||||
// Automatically fetch all pages until no more data
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
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]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingCalendarInner
|
||||
{...props}
|
||||
data={data}
|
||||
allowedFilterIds={allowedFilterIds}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
+16
-73
@@ -6,44 +6,27 @@ 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 { 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 +67,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
|
||||
@@ -150,14 +92,15 @@ export function BookingsCalendarView({
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
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,9 +34,14 @@ 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 { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import {
|
||||
useBookingDetailsSheetStore,
|
||||
useBookingDetailsSheetStoreApi,
|
||||
@@ -86,35 +99,52 @@ function BookingDetailsSheetInner({
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Fetch additional booking details for reschedule information
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
enabled: Boolean(booking.rescheduled || booking.fromReschedule),
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// Get navigation state directly from the store
|
||||
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
|
||||
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedBookingId(null);
|
||||
setSelectedBookingUid(null);
|
||||
};
|
||||
|
||||
const storeApi = useBookingDetailsSheetStoreApi();
|
||||
|
||||
const handleNext = () => {
|
||||
const nextId = storeApi.getState().getNextBookingId();
|
||||
if (nextId !== null) {
|
||||
setSelectedBookingId(nextId);
|
||||
const nextUid = storeApi.getState().getNextBookingUid();
|
||||
if (nextUid !== null) {
|
||||
setSelectedBookingUid(nextUid);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const prevId = storeApi.getState().getPreviousBookingId();
|
||||
if (prevId !== null) {
|
||||
setSelectedBookingId(prevId);
|
||||
const prevUid = storeApi.getState().getPreviousBookingUid();
|
||||
if (prevUid !== null) {
|
||||
setSelectedBookingUid(prevUid);
|
||||
}
|
||||
};
|
||||
|
||||
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,16 +178,41 @@ 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}
|
||||
onClick={(e) => {
|
||||
@@ -167,7 +222,8 @@ function BookingDetailsSheetInner({
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!hasNext}
|
||||
onClick={(e) => {
|
||||
@@ -175,33 +231,45 @@ function BookingDetailsSheetInner({
|
||||
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,64 @@ 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} />
|
||||
</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 +342,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 +405,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 +466,10 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
bookingStatus: booking.status,
|
||||
});
|
||||
|
||||
if (booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!locationToDisplay) {
|
||||
return null;
|
||||
}
|
||||
@@ -345,7 +494,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 +521,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 +532,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 +606,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 +660,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 +668,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,12 +793,54 @@ 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 Section({
|
||||
title,
|
||||
className,
|
||||
@@ -502,8 +851,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">
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user