Compare commits

...
Author SHA1 Message Date
github-actions[bot] fd61c00ad1 chore: version packages (#23484)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-02 23:07:19 +00:00
Shrey-Sutariya d8f422f98d Fix (#23523) 2025-09-02 22:27:19 +00:00
Harsh Jadhav 7f200df1ac fix(workflows): hide organizer email in workflow emails (#23525) 2025-09-02 22:25:32 +00:00
Udit Takkar 9b38ed65c0 feat: add delete recording script (#23169)
* feat: add script for deleting recordings

* chore: move

* refactor: 6 months

* refactor: add commnet

* chore: add command

* fix: use UTC moths
2025-09-02 23:56:37 +05:30
Joe Au-Yeung 25c20e5093 fix: Handle personal billing portal on backend (#23521) 2025-09-02 13:08:04 -04:00
Keith Williams ec8a441649 chore: Remove DBA as CODEOWNER (#23522) 2025-09-02 18:00:11 +01:00
Benny Joo 04a91dc71a perf: server side fetching for /settings/my-account/conferencing (#23358)
* wip

* fix

* refactor
2025-09-02 17:46:05 +01:00
Volnei Munhoz 2023926a3a chore: avoid circular deps (#23520) 2025-09-02 16:02:56 +00:00
75465ac4bf feat: PBAC on team members page + org members page to use specific permissi… (#23004)
* PBAC on team members page + org members page to use specific permissions with fallbacks

* Implement checks on edit sheet fetch to check for permisisons

* WIP permission fixes

* fix tests

* WIP

* fix edit mode perms

* fix remove and list view permissions

* fix add

* feat: add pbac permissions for impersonation (#23035)

* registery + migration + i18n

* UI for can impersonate

* impersonation backend

* remove permission from db query

* add missing prisma import

* add resend invitation and edit mode checks for delete/changeRole

* fix canChangeRole

* canChangeRole gate

* fix permission logic

* fix depends on

* push migration

* fix migration

* fix scoped advanced permissions

* use listMembers instead of invite proxy

* remove isOrgAdmin check to rely on pbac

* wait for session to load before navigating

* use event-types testId to ensure session loaded

* use profile page instead of eventTypes

* use correct roles

* fix wait for session

* correct orgs private isFallBackRoles

* set a timeout on waiting for session

* remove adminOrOwner check since its in fallpack

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-09-02 12:52:45 -03:00
Lauris Skraucis 2dbd0b3832 fix: event type settings name field default label (#23510)
* fix: event type settings name field default label

* fix: unit test

* fix: e2e tests
2025-09-02 12:46:33 -03:00
Volnei Munhoz 3b7d97b194 chore: add eslint rules to avoid importing from app-store (#23518) 2025-09-02 15:24:09 +00:00
Keith WilliamsandDevin AI a1fab0e744 fix: update E2E Atoms workflow naming to match convention (#23512)
- Change workflow name from 'Atoms E2E Tests' to 'E2E Atoms'
- Update job name to match new convention
- Update step name for consistency
- Aligns with naming pattern used by other E2E workflows

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-02 13:44:13 +00:00
ozerandAnik Dhabal Babu 87331f0a3a fix(availability): prevent hover animation jump when switching days (#22209)
* fix(availability): prevent hover animation jump when switching days

* Update CellHighlightContainer.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-02 13:25:31 +00:00
6cd4f1fcaa fix(payment): only send payment email to booking user in seated events (#22558)
* fix(payment): only send payment email to booking user in seated events

Fixes #22524. Prevents previously booked users from receiving duplicate payment emails.

* Update PaymentService.ts

* Update PaymentService.ts

* Update PaymentService.ts

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-02 12:39:26 +00:00
Eunjae Leeandcoderabbitai[bot] 6b84dc09a7 docs: add AGENTS.md (#23426)
* add docs for agents

* update

* Update .agents/README.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-02 14:28:52 +02:00
Devanshu SharmaandAnik Dhabal Babu d125064430 feat: add iCalSequence to new booking data (#22899)
* feat: add iCalSequence to new booking data

* fix: update iCalSequence logic for rescheduled bookings

* Update createBooking.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-02 11:57:59 +00:00
Keith Williams 39490b6883 revert: "fix: prevent non-E2E labels from triggering workflow runs (#23459)"
This reverts commit e45f9c5a13.
2025-09-02 08:50:29 -03:00
Keith WilliamsandDevin AI e45f9c5a13 fix: prevent non-E2E labels from triggering workflow runs (#23459)
* fix: prevent non-E2E labels from triggering workflow runs

- Add conditional logic to all jobs to skip when non-ready-for-e2e labels are added
- Only allow ready-for-e2e label additions to trigger workflows
- Preserve all other trigger types (opened, synchronize, reopened)
- Fixes issue where labels like 'Improvements' cause unnecessary CI runs

Resolves the annoyance where adding labels like 'Improvements' to PRs
triggers full workflow runs including all checks, when only the
ready-for-e2e label should trigger E2E tests and associated builds.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: consolidate workflow conditional logic into environment variable

- Replace repeated conditional logic with single SKIP_WORKFLOW env variable
- Simplify all job conditions from complex expression to env.SKIP_WORKFLOW != 'true'
- Maintain exact same functional behavior while improving maintainability
- Reduces code duplication across 20+ jobs in the workflow

This addresses feedback to consolidate the repeated conditional logic:
github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e'

The refactored approach is cleaner and easier to maintain.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: update required job logic to handle skipped jobs correctly

- Remove 'skipped' from failure conditions in required job
- Add SKIP_WORKFLOW condition to required job logic
- Prevents false failures when E2E jobs legitimately skip
- Maintains proper failure detection for actual job failures

This fixes the CI failure where the required job was treating
legitimately skipped E2E jobs as failures when no ready-for-e2e
label is present.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-02 11:44:11 +00:00
Hariom Balhara 9ceb5ea3c5 Skeleton for Booking flow refactor using Factory and services (#23277) 2025-09-02 11:06:31 +00:00
AnshumancanrockandAnik Dhabal Babu 16bd52148e fix: Username Overflow Fix using Ellipsis and tooltip (#22570)
* Username Overflow Fix

* fix

* fix

* fix

* Update signup-view.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-02 11:00:06 +00:00
Lauris Skraucis 9a2705c84c fix: booking field name (#23508)
* Revert "fix: dutch appearing in booking field name (#23364)"

This reverts commit 67befea66e.

* fix: dont mutate default booking field
2025-09-02 10:32:31 +00:00
6c069a4afe feat: skip onboarding and fix UI for redirect apps (#23115)
* skip onboarding and fix UI for redirect apps

* fix

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2025-09-02 04:54:06 +00:00
emrysal 42cbc46609 chore: release v5.6.11
Post release / release (push) Canceled after 0s
2025-09-02 01:04:43 +00:00
Volnei MunhozandAlex van Andel 096026b92a fix: Vitest is being bundled to client + bump lingo.dev version (#23502)
* Fix vitest on prod

* Also extract out verify-email for the purpose of stopping test bundling

* Address NIT

* Fix lingo.dev imports

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-02 01:58:08 +01:00
Eunjae Lee 060eafac60 fix: add new backfill script for RoutingFormResponseDenormalized (#23166) 2025-09-02 01:42:11 +01:00
Anik Dhabal Babu f6f36750ec chore: CSRF protect cancel functionality (#23439)
* add csrf endpoint

* Update booking-pages.e2e.ts

* Update zod-utils.ts

* fix type error
2025-09-01 23:18:50 +00:00
sean-brydon 2e9aa01771 fix: e2e test - wait for session to load next step (#23489)
* wait for session to load before navigating

* use event-types testId to ensure session loaded

* use profile page instead of eventTypes
2025-09-02 00:57:24 +05:30
abhix4andAnik Dhabal Babu 40d70cd1d4 fix: regex and id extraction for giphy media urls (#22276)
* fix regex and id extraction for giphy media urls

* Update get.ts

* Update get.ts

* tweak

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-01 18:28:00 +00:00
Eunjae Lee 17ef0dc12b feat: add collapsible support to PanelCard component (#23366)
* feat: add collapsible support to PanelCard component

- Add collapsible and defaultCollapsed props to PanelCard
- When collapsible is enabled, title becomes clickable with chevron icon
- Chevron icon indicates collapse state and rotates on toggle
- Body content is hidden when collapsed
- Maintains backward compatibility with existing usage
- Follows FormCard's collapsible pattern implementation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

^ Conflicts:
^	packages/ui/components/card/PanelCard.tsx

* update style

* address feedback

* animate expanding & collapsing panel
2025-09-01 15:16:58 +00:00
Eunjae LeeandDevin AI e530ad5412 feat: add toggleable legend functionality to ChartCard components (#23390)
* feat: add toggleable legend functionality to ChartCard components

- Create useToggleableLegend hook for managing enabled/disabled series
- Update ChartCard to accept enabledLegend and onSeriesToggle props
- Add click handlers and visual feedback to Legend component
- Apply toggleable functionality to EventTrendsChart and RoutingFunnel
- Maintain backwards compatibility with existing charts

Features:
- Visual feedback: 50% opacity for disabled items, hover effects, cursor pointer
- Interactive legend items toggle chart series on/off
- Hook uses TypeScript generics for different legend item types
- Conditional rendering of chart series based on enabled legend items
- Full backwards compatibility - existing charts work unchanged

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* address feedback

* address feedback

* do not use !

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-01 14:54:30 +00:00
882eb85a33 fix(phone input): trigger validation and formatting for prefill query… (#23123)
* fix(phone input): trigger validation and formatting for prefill query values

* fix: strengthen phone input sanitization to prevent edge cases

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-01 14:38:57 +00:00
Hariom Balhara 6b0726bcde feat: Allow disabling auto-scroll using ui.disable-auto-scroll (#23462) 2025-09-01 14:21:30 +00:00
Rajiv Sahal 712467a7f4 fix: bring back currency param for checkout creation when updating team subscription (#23488)
* fix: make sure customer id is always defined and add logs

* fix: bring back currency param for checkout creation when updating team subscription
2025-09-01 18:31:54 +05:30
Amit Sharma 0a9e732ea4 fix: Plain chat not opening from help button for free users and on bookings list page (#23491) 2025-09-01 12:28:01 +00:00
b101157d67 fix(navigation): make child nav links accessible on tablet widths (#23138)
* fix(navigation): make child links clickable on tab widths

* fix(navigation): highlight only active child route & remove underline animation

* fix(navigation): onclick single open tooltip and Tailwind color consistency for theme support

* Update NavigationItem.tsx

* Update Navigation.tsx

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-01 11:51:24 +00:00
emrysal f859c289aa chore: release v5.6.10
Post release / release (push) Canceled after 0s
2025-09-01 11:29:04 +00:00
Anik Dhabal Babu 1009d2b4bc fix: few flaky e2e tests (#23487)
* fix: few falky e2e tests

* Update workflows.ts

* Update workflows.ts
2025-09-01 14:43:53 +05:30
Keith WilliamsandDevin AI 1739114490 perf: separate task creation from processing to eliminate compilation overhead (#23485)
* refactor: separate task creation from processing to eliminate compilation overhead

- Create TaskProcessor class to handle task processing logic
- Move processQueue and cleanup methods to TaskProcessor
- Remove task handler imports from InternalTasker creation path
- Eliminate 2.5-3s compilation overhead by only loading task handlers during processing
- Maintain existing tasker.create() API for all callers
- Use composition pattern for clean separation of concerns

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

refactor: completely separate TaskProcessor from InternalTasker

- Remove processQueue() and cleanup() methods from Tasker interface
- Eliminate TaskProcessor dependency from InternalTasker class
- Update API endpoints (cron.ts, cleanup.ts) to use TaskProcessor directly
- Update README documentation to show correct TaskProcessor usage
- Complete separation ensures task creation no longer imports task handlers
- Eliminates 2.5-3s compilation overhead while preserving all functionality

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Moved the cleanup method back to internal tasker

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-01 07:08:06 +00:00
Lauris Skraucis 9724bc07a6 feat: enable italian for atoms (#23445)
* fix: typo

* feat: add italian language to atoms

* chore: add changelog entry

* docs: document available atoms languages
2025-09-01 02:56:32 +00:00
sean-brydonandcoderabbitai[bot] 24ff3622fd fix: fixes PBAC roles creation and update when adding Attributes (#23443)
* wip

* restore lock

* remove debug

* add tests + extract util

* Update packages/features/pbac/services/permission.service.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix parse permission string

* use correct permission strings in tests

* use eventType instead of event

* fix import

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-01 08:08:13 +05:30
Joe Au-YeungandDevin AI f1d1674693 feat: allow organization owners access to shared billing portal (#23451)
* feat: allow organization owners access to shared billing portal

- Modify stripepayment portal endpoint to accept teamId parameter
- Use team's subscription ID to get customer ID from Stripe subscription
- Validate user permissions before allowing team billing access
- Update platform and regular billing views to pass teamId
- Maintain backward compatibility with individual user billing

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add `getTeamByIdIfUserIsAdmin` to `TeamRepository`

* Add `getSubscriptionFromId` function

* Generate portal URL via the team's subscription not the requesting
user's customerId

* Pass teamId to `/portal` endpoint

* Undo Devin change to platform billing

* Address coderabbit comments

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-01 08:07:36 +05:30
c87a88b244 chore: remove .yarn/ci-cache from git and ignore it (#22755)
* chore: remove .yarn/ci-cache from git and ignore it

* Remove additional install-state.gz cache files

* Small fixes to the .gitignore

---------

Co-authored-by: Matthew Cengia <mattcen@mattcen.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-31 21:42:34 +00:00
Keith WilliamsandDevin AI 8bdcf4ec3b feat: remove unused features barrel file for better performance (#23456)
- Remove packages/features/index.ts barrel file that only exported ee namespace
- No imports were found using this barrel file - all imports use specific module paths
- This improves performance by eliminating unnecessary exports from ee folder
- Type checking passes confirming no breaking changes

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-31 19:58:34 +00:00
5f0188b036 fix: cancel event button for each booking on bookings page (#23479)
* Cancel Event Fix

* update

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-31 21:38:17 +05:30
bf3037d57e fix: add cache invalidation after creating Availability (#23221)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-31 14:18:47 +00:00
Hemant M MehtaandAnik Dhabal Babu 5ebd33576a fix: timezone-dropdown (#23469)
* fix: timezone-dropdown

closes: #23453

* coderabbit suggestion

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>

* Update timezone.ts

---------

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-31 08:20:19 +00:00
Bandhan MajumderandAnik Dhabal Babu 4fb3639f71 refactor: error message for notification subscription failure (#23304)
* improve: error message for notification subscription failure

* suggestions

* Update WebPushContext.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-31 08:12:44 +00:00
VaibhavandKartik Saini d44a41c587 feat: Add search functionality for variables in workflow (#23191)
* Add search functionality for variables in workflow

* feat: Add search functionality for variables in workflow - updated

* fix failing test

* fix UI of AddVariable

* fix: clear stale input value

* fix: locale

* fix: locale

* chore

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-30 22:06:53 +05:30
Saurabh SinghandAnik Dhabal Babu 4960c4b97c chore: update svg path (#23460)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-30 15:52:00 +00:00
Bandhan MajumderandAnik Dhabal Babu 5c51b0632f fix: unordered list issue (#23454)
* fix: uordered list issue

* changes

* revert

* Update EventMeta.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-30 15:37:10 +00:00
Sahitya ChandraandKartik Saini 73b0693632 fix: remove event type URL for managed event types (#23357)
* fix(event-types): remove event type URL for managed event types

* /teamSlug/eventTypeSlug returns 404 for managed event types

* chore

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-30 20:58:58 +05:30
0bcb81bedd feat: to enable optimized slots (#16579)
* feature to enable optimized slots

* type check (was not caught running locally)

* update for typechecks

* update for type checks

* initialize form

* update slots test for showOptimizedSlots

* added some more tests

* handled edge case

* added test for edge case

* generalized condition

* refactored instead of ternary and added comments

* update condition

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* moved showOptimizedSlots setting to advanced tab

* updated to always move to next 15min if not possible to next interval

* correct after merge

* chore

* fix typecheck after latest merges

* remove workinghours when dateRanges input

* refactor: extract to fn

* chore

* remove unrelated changes

* remove unrealted change

* updated tests

* unrelated format changes no-verify

* unrelated format changes no-verify

* handle current day booking cases, to show roundedoff minutes

* remove unrelated format changes in openapi.json

* add test for new condition

* remove unrelated auto-format changes

* remove unrelated auto-format changes

* update after merge

* For current day bookings, normalizing the seconds to zero to avoid issues with time calculations

* Update packages/platform/atoms/event-types/hooks/useEventTypeForm.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* update for typecheck due to latest changes

* resolve merge conflicts

* Smaller description

* Minor style change to prefer early return

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-30 14:57:24 +00:00
Joe Au-Yeung bfa23ddb80 refactor: Remove app store index (#23449)
* Remove app store index

* Remove reliance on app-store mock

* Remove app store mock
2025-08-29 18:41:07 +00:00
Anik Dhabal Babu 28f473da14 fix: unable to cancel seated event due to booking responses (#23447)
* fix: unable to cancel event

* Update editLocation.handler.ts
2025-08-29 18:15:06 +00:00
Joe Au-Yeung 1fd1202373 fix: Allow install on teams for concurrent meeting apps (#23429) 2025-08-29 09:59:47 -04:00
Benny Joo 8845e736e5 fix: hydration mismatch error in UserAvatarGroup component (#23425) 2025-08-29 14:29:00 +02:00
Udit Takkar dd16c2b99d refactor: small improvements for cal ai (#23444)
* refactor: small improvements follow up

* fix: add docs
2025-08-29 17:42:15 +05:30
e7779bba0a feat(teams): show org upgrade tip for one team and add singular copy on /teams (#23367)
* feat(teams): show org upgrade tip for one team and add singular copy on /teams

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* use constant

* extract i18n

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2025-08-29 10:11:57 +00:00
701c834115 chore: upgrade Mintlify from v4.0.436 to v4.2.87 (#23418)
- Updated GitHub Actions workflow to use latest stable Mintlify version
- Tested local build compatibility with existing mint.json configuration
- Verified no breaking changes or type errors introduced
- Both mint.json and docs.json configurations supported in new version

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ali@cal.com <ali@cal.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-08-29 09:39:50 +00:00
sean-brydonandEunjae Lee 18423e5805 feat: (PBAC) Introduce depends on permission registery (#23440)
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-08-29 09:33:24 +00:00
Udit Takkar b332d9897d refactor: add logging (#23420) 2025-08-29 08:06:22 +00:00
Keith WilliamsandDevin AI 191db5104a perf: optimize video adapter imports to avoid loading entire app store (#23435)
* perf: optimize video adapter imports to avoid loading entire app store

- Creates VideoApiAdapterMap with lazy imports for 12 video services
- Updates getVideoAdapters function to use VideoApiAdapterMap instead of dynamic app store imports
- Preserves zoom app name parsing logic (zoom_video → zoomvideo)
- Follows same optimization pattern as calendar, analytics, and payment services
- Reduces bundle size by avoiding import of 100+ apps when only video functionality needed

Affected files:
- packages/app-store-cli/src/build.ts: Added video service generation logic
- packages/lib/videoClient.ts: Updated to use VideoApiAdapterMap
- packages/features/bookings/lib/handleCancelBooking.ts: Updated FAKE_DAILY_CREDENTIAL import
- packages/lib/EventManager.ts: Updated FAKE_DAILY_CREDENTIAL import
- packages/trpc/server/routers/viewer/calVideo/getMeetingInformation.handler.ts: Updated to use VideoApiAdapterMap
- apps/web/lib/video/[uid]/getServerSideProps.ts: Updated daily video function imports
- packages/app-store/video.services.generated.ts: Generated video adapter map with re-exports

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: add missing re-exports to video.services.generated.ts

- Updates build.ts to include FAKE_DAILY_CREDENTIAL and other daily video function re-exports
- Fixes type errors in EventManager.ts and other files importing from video.services.generated
- Ensures video adapter refactoring maintains all existing functionality

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: update video adapter test mocks to work with VideoApiAdapterMap

- Creates global mockVideoAdapterRegistry for dynamic video adapter mocks
- Uses Proxy in vi.mock for VideoApiAdapterMap to return registered mocks
- Updates mockVideoApp and mockErrorOnVideoMeetingCreation to register mocks
- Fixes unit test failures in booking scenario tests
- Ensures video meeting operations (createMeeting, updateMeeting, deleteMeeting) work correctly

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove re-exports from video.services.generated.ts and revert imports

- Remove re-export block from video.services.generated.ts as requested
- Revert imports back to pull directly from dailyvideo/lib/VideoApiAdapter
- Update build.ts to not generate the re-exports
- Maintains all existing functionality while addressing GitHub feedback

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: rename video.services.generated.ts to video.adapters.generated.ts

- Updates build.ts to generate video.adapters.generated.ts instead of video.services.generated.ts
- Updates all import statements to use new filename
- Removes unnecessary mock exports from bookingScenario.ts (FAKE_DAILY_CREDENTIAL, etc.)
- Addresses GitHub comments from @keithwillcode on PR #23435

The terminology change from 'services map' to 'adapters map' better reflects
the actual content (video adapters, not services) and maintains consistency
with the established refactoring pattern.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: rename videoServices to videoAdapters and simplify return statement

- Rename variable from videoServices to videoAdapters for consistency
- Remove unnecessary const variable and return directly in same line
- Addresses GitHub comments from @keithwillcode on PR #23435

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-29 14:21:05 +09:00
emrysal bfacd8a780 chore: release v5.6.9
Post release / release (push) Canceled after 0s
2025-08-29 04:31:17 +00:00
d4bff9d6b1 feat: Cal.ai Self Serve #2 (#22995)
* feat: Cal.ai Self Serve #2

* chore: fix import and remove logs

* fix: update checkout session

* fix: type errors and test

* fix: imports

* fix: type err

* fix: type error

* fix: tests

* chore: save progress

* fix: workflow flow

* fix: workflow update bug

* tests: add unit tests for retell ai webhoo

* fix: status code

* fix: test and delete bug

* fix: add dynamic variables

* fix: type err

* chore: update unit test

* fix: type error

* chore: update default prompt

* fix: type errors

* fix: workflow permissions

* fix: workflow page

* fix: translations

* feat: add call duration

* chore: add booking uid

* fix: button positioning

* chore: update tests

* chore: improvements

* chore: some more improvements

* refactor: improvements

* refactor: code feedback

* refactor: improvements

* feat: enable credits for orgs (#23077)

* Show credits UI for orgs

* fix stripe callback url when buying credits

* give orgs 20% credits

* add test for calulating credits

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* fix: types

* fix: types

* chore: error

* fix: type error

* fix: type error

* chore: mock env

* feat: add idempotency key to prevent double charging

* chore: add userId and teamId

* fix: skip inbound calls

* chore: update tests

* feat: add feature flag for voice agent

* feat: finish test call and other improvements

* chore: add alert

* chore: update .env.example

* chore: improvements

* fix: update tests

* refactor: remove un necessary

* feat: add setup badge

* chore: improvements

* fix: use referene id

* chore: improvements

* fix: type error

* fix: type

* refactor: change pricing logic

* refactor: update tests

* fix: conflicts

* fix: billing link for orgs

* fix: types

* refactor: move feature flag up

* fix: alert and test call credit check

* fix: update unit tests

* fix: feedback

* refactor: improvements

* refactor: move handlers to separate files

* fix: types

* fix: missing import

* fix: type

* refactor: change general tools functions handling

* refactor: use repository

* refactor: improvements

* fix: types

* fix: type errorr

* fix: auth check

* feat: add creditFor

* fix: update defualt prompt

* fix: throw error on frontend

* fix: update unit tests

* fix: use deleteAllWorkflowReminders

* refactor: add connect phone number

* refactor: improvements

* chore: translation

* chore: update message

* chore: translation

* design improvements buy number dialog

* add translation for error message

* use translation key in error message

* refactor: improve connect phone number tab

* feat: support un saved workflow to tests

* chore: remove un used

* fix: remove un used

* fix: remove un used

* refactor: similify billing

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-29 05:04:05 +01:00
6defa1cd7d refactor: replace @prisma/client/runtime imports with public API (#23087)
- Replace PrismaClientKnownRequestError and other error classes with Prisma namespace equivalents
- Remove internal DefaultArgs and InternalArgs type imports from Prisma extensions
- Ensure all error handling uses stable public API exports
- Maintain compatibility with future Prisma versions by avoiding runtime dependencies

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-29 03:09:52 +01:00
Benny Joo 5b2b5fd967 chore: remove unused code including /api/nope (#23323)
* remove some unused code

* remove /api/nope
2025-08-29 01:09:02 +00:00
Vitor Monteiro 6932498d48 fix: event type empty title validation (#23220) 2025-08-28 19:22:59 +00:00
Anirban SinghaandKartik Saini e07f800417 fix: incorrect placeholder for start of week (#23402)
* fix: incorrect placeholder for start of week

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* Trigger workflows

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-28 23:20:33 +05:30
Anik Dhabal Babu 464e5f1496 Revert "chore: Add zoom waiting room setting (#22888)" (#23427)
This reverts commit c64b663514.
2025-08-28 16:16:57 +00:00
724e3f2755 fix: type error in the EventAvailabilityTabPlatformWrapper component (#21983)
* fix: add required props in scheduleQueryData

* chore: remove any to fix runtime type checking

* chore: add a fallback to avoid runtime err

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-28 16:11:51 +00:00
Eunjae LeeandDevin AI 59c76141ce feat: add Recent No-Show Guests chart to insights page (#23381)
* feat: add Recent No-Show Guests chart to insights page

- Add RecentNoShowGuestsChart component with ChartCard wrapper
- Add tRPC handler for recentNoShowGuests query
- Add getRecentNoShowGuests method to InsightsBookingBaseService
- Display guest name, booking time, event type, and copy email button
- Filter for bookings where ALL attendees are no-shows
- Add translation strings for new UI elements
- Integrate chart into insights view grid layout

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* re-order charts

* clean up

* feat: add optional tooltip functionality to PanelCard

- Add titleTooltip prop to PanelCard component with InfoBadge
- Pass through titleTooltip prop in ChartCard
- Add tooltip to RecentNoShowGuestsChart explaining complete no-show filtering
- Add translation string for tooltip explanation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* style adjustment

* update style

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 14:01:27 +00:00
Eunjae LeeandDevin AI a494428334 refactor: FailedBookingsByField to use Insights Routing Service (#23259)
* refactor: FailedBookingsByField to use Insights Routing Service

- Move getFailedBookingsByRoutingFormGroup logic to InsightsRoutingBaseService
- Replace legacy getWhereForTeamOrAllTeams with getBaseConditions()
- Add failedBookingsByFieldInputSchema with date filtering support
- Update tRPC endpoint to use createInsightsRoutingService pattern
- Update component to pass startDate/endDate parameters
- Remove legacy method from routing-events.ts

Follows same pattern as PR #23031 for RoutedToPerPeriod refactoring

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: use useInsightsRoutingParameters hook in FailedBookingsByField

- Add missing import for useInsightsRoutingParameters
- Update component to follow same pattern as PR #23031
- Add columnFilters support to failedBookingsByFieldInputSchema
- Maintain routingFormId parameter from useInsightsParameters

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove separate routingFormId parameter

- Remove routingFormId parameter from getFailedBookingsByFieldData method
- Update component to only use insightsRoutingParameters
- Remove routingFormId from failedBookingsByFieldInputSchema
- Let getBaseConditions handle all filtering including routing form filtering
- Simplify tRPC endpoint to not pass separate routingFormId

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* clean up

* fix

* refactor: use RoutingFormResponseDenormalized directly in getFailedBookingsByFieldData

- Replace complex CTE approach with direct denormalized table query
- Join with App_RoutingForms_Form to access field definitions from JSON
- Use RoutingFormResponseField for response values
- Filter failed bookings with bookingUid IS NULL
- Maintain same data structure for UI compatibility
- Follow existing service patterns for authorization and date filtering

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fixing failed bookings query

* fix query

* fix integration test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 22:26:32 +09:00
Eunjae Lee 7145a2d640 refactor: Routed to Per Period to use Insights Routing Service (#23031)
* fix: refactor RoutedToPerPeriod to use Insights Routing Service

* clean up

* debounce search and increase stale time

* fix wrongn conflict merge

* clean up DownloadButton

* fix query error

* address feedback

* apply feedback

* remove unnecessary CTE

* fix limit

* make startDate and endDate required

* fix type

* remove irrelevant test case

* address feedback
2025-08-28 22:25:55 +09:00
Keith WilliamsandDevin AI baaa04151d perf: optimize payment app imports to avoid loading entire app store (#23408)
* perf: optimize payment app imports to avoid loading entire app store

- Add PaymentServiceMap generation to app-store-cli build process
- Generate payment.services.generated.ts with lazy imports for 6 payment services
- Update handlePayment.ts, deletePayment.ts, handlePaymentRefund.ts to use PaymentServiceMap
- Update getConnectedApps.ts and tRPC payment routers to use PaymentServiceMap
- Follow same pattern as analytics optimization in PR #23372
- Reduces bundle size by avoiding import of 100+ apps when only payment functionality needed

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Update build.ts

* fix: update payment service test mocking to work with PaymentServiceMap

- Remove obsolete appStoreMock line from bookingScenario.ts since handlePayment now uses PaymentServiceMap
- Update setupVitest.ts to import prismaMock from correct PrismockClient instance
- Add PaymentServiceMap mock following PR #22450 pattern for calendar services
- Ensure MockPaymentService uses consistent externalId across test files
- Fix webhook handler to return 200 status by ensuring payment records are found correctly

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert prismaMock import to avoid interfering with other tests' vi.spyOn() calls

- Remove global prismaMock import from setupVitest.ts that was causing 'is not a spy' errors
- Update MockPaymentService to import prismaMock locally to maintain payment test functionality
- Fixes organization and outOfOffice tests while preserving payment service optimization

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove E2E conditional check from payment services map generation

- Payment services map now always includes all payment apps regardless of E2E environment
- Ensures payment functionality is consistently available across all environments
- Addresses CI failures caused by conditional payment service loading

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: use direct PaymentService imports instead of .lib structure

- Update app-store-cli to import directly from lib/PaymentService.ts files
- Modify all payment handlers to access PaymentService directly
- Update test mocks to match new direct import structure
- Remove .lib property access pattern across payment system
- Maintain backward compatibility while improving import efficiency

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert chargeCard booking.id parameter additions

- Remove booking.id parameter from chargeCard calls in chargeCard.handler.ts and payments.tsx
- Addresses GitHub feedback to investigate chargeCard signature changes in separate PR
- Keeps all other direct PaymentService import refactor changes intact

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 10:05:36 -03:00
Benny Joo 381125becd chore: CODEOWNER change for /apps/web/lib files (#23423) 2025-08-28 12:11:06 +00:00
324 changed files with 15209 additions and 3576 deletions
+82
View File
@@ -0,0 +1,82 @@
# Cal.com Development Guide for AI Agents
This directory contains comprehensive documentation for AI agents working on the Cal.com codebase.
## Quick Navigation
- **[Commands](commands.md)** - Build, test, and development commands
- **[Knowledge Base](knowledge-base.md)** - Knowledge base & best practices
- **[Architecture Overview](#architecture-overview)** - System structure and patterns
## Getting Started
Cal.com is a monorepo using Yarn workspaces and Turbo for build orchestration. The main application is in `apps/web/` with shared packages in `packages/`.
### Key Directories
- `apps/web/` - Main Next.js application
- `packages/prisma/` - Database schema and migrations
- `packages/trpc/` - API layer using tRPC
- `packages/ui/` - Shared UI components
- `packages/features/` - Feature-specific code
- `packages/app-store/` - Third-party app integrations
## Architecture Overview
### Database Layer
- **Prisma ORM** with PostgreSQL
- Schema in `packages/prisma/schema.prisma`
- Always use `select` instead of `include` for better performance
- Never expose `credential.key` field in API responses
### API Layer
- **tRPC** for type-safe APIs
- Routers in `packages/trpc/server/routers/`
- Authentication handled via NextAuth.js
### Frontend
- **Next.js 13+** with App Router in some areas
- **React 18** with TypeScript
- **Tailwind CSS** for styling
- Internationalization with `next-i18next`
## Common Patterns
### Error Handling
- Use early returns to reduce nesting
- Throw descriptive errors with proper error codes
- Prefer composition over prop drilling
### Performance
- Avoid O(n²) logic in backend code
- Minimize Day.js usage in performance-critical paths
- Use `select` queries to only fetch needed data
- Consider using `.utc()` for Day.js operations
### Security
- Never commit secrets or API keys
- Always validate input data
- Use proper authentication checks
- Never expose sensitive credential fields
## Testing Strategy
- **Unit tests** with Vitest
- **Integration tests** for complex workflows
- **E2E tests** with Playwright
- Test files use `.test.ts` or `.spec.ts` extensions
## Pull Request Guidelines
For large PRs (>500 lines or >10 files):
- Split by feature boundaries
- Separate database migrations, backend logic, frontend components
- Create dependency chains that can be merged sequentially
- Pattern: Database → Backend → Frontend → Tests
+56
View File
@@ -0,0 +1,56 @@
# Coding Standards & Best Practices
## Import Guidelines
### Type Imports
```typescript
// ✅ Good - Use type imports for TypeScript types
import type { User } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
// ❌ Bad - Regular import for types
import { User } from "@prisma/client";
```
## Code Structure
### Early Returns
- Prefer early returns to reduce nesting: `if (!booking) return null;`
### Composition Over Prop Drilling
- Use React children and context instead of passing props through multiple components
### Security Rules
```typescript
// ❌ NEVER expose credential keys
const user = await prisma.user.findFirst({
select: {
credentials: {
select: {
key: true, // ❌ NEVER do this
}
}
}
});
// ✅ Good - Never select credential.key field
const user = await prisma.user.findFirst({
select: {
credentials: {
select: {
id: true,
type: true,
// key field is excluded for security
}
}
}
});
```
+83
View File
@@ -0,0 +1,83 @@
# Build, Test & Development Commands
## Development Commands
- `yarn dev` - Start development server for web app
- `yarn dev:all` - Start web, website, and console apps
- `yarn dev:api` - Start web app with API proxy and API
- `yarn dev:console` - Start web app with console
- `yarn dx` - Start development with database setup
## Build Commands
- `yarn build` - Build all packages and apps
- `yarn build:ai` - Build AI package specifically
- `yarn clean` - Remove build artifacts (node_modules, .next, .turbo, dist)
## Lint & Type Check
- `yarn lint` - Run ESLint on codebase
- `yarn lint:fix` - Run ESLint and fix issues
- `yarn lint:report` - Generate lint report
- `yarn type-check` - Run TypeScript type checking
- `yarn format` - Format code with Prettier
## Testing Commands
### Unit Tests
- `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 tdd` - Run tests in watch mode
- `yarn test:ui` - Run tests with UI interface
### End-to-End Tests
- `yarn e2e` - Run end-to-end tests (Playwright)
- `yarn e2e <filename>` - Run E2E tests for specific file
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test by name
- `yarn e2e:app-store` - Run app store E2E tests
- `yarn e2e:embed` - Run embed E2E tests
- `yarn test-e2e` - Run database seed + E2E tests
## Database Commands
- `yarn prisma` - Run Prisma CLI commands
- `yarn db-seed` - Seed database with test data
- `yarn db-deploy` - Deploy database migrations
- `yarn db-studio` - Open Prisma Studio
- `psql "postgresql://postgres:@localhost:5432/calendso"` - Connect to local PostgreSQL database
## App Store Commands
- `yarn create-app` - Create new app store integration
- `yarn edit-app` - Edit existing app
- `yarn delete-app` - Delete app
- `yarn app-store:build` - Build app store
- `yarn app-store:watch` - Watch app store for changes
## Useful Development Patterns
### Running Single Tests
```bash
# Unit test specific file
yarn vitest run packages/lib/some-file.test.ts
# Integration test specific file
yarn test routing-form-response-denormalized.integration-test.ts -- --integrationTestsOnly
# E2E test specific file
yarn e2e tests/booking-flow.e2e.ts
# Run specific test by name
yarn e2e tests/booking-flow.e2e.ts --grep "should create booking"
```
### Environment Setup
- Copy `.env.example` to `.env` and configure
- Copy `.env.appStore.example` to `.env.appStore` for app store development
- Run `yarn dx` for initial development setup with database
+320
View File
@@ -0,0 +1,320 @@
# Knowledge Base - Product & Domain-Specific Information
## Repo note for calcom/cal.com
The whole thing is a monorepo. You need to be working in the apps/web folder.
Linting and Formatting
- Run lint with report generation: `yarn lint:report`
- Run type checking: `yarn type-check:ci --force`
- Run auto-fix: `yarn lint -- --fix`
Development
- Install dependencies: `yarn`
- Set up environment:
- Copy `.env.example` to `.env`
- Generate keys with `openssl rand -base64 32` for both `NEXTAUTH_SECRET` and `CALENDSO_ENCRYPTION_KEY`
- Configure Postgres database URL in `.env`
- Set DATABASE_DIRECT_URL to the same value as DATABASE_URL
Database Setup
- Development: `yarn workspace @calcom/prisma db-migrate`
- Production: `yarn workspace @calcom/prisma db-deploy`
When setting up local development database, it'll create a bunch of users for you. The passwords are the same as the username. e.g. 'free:free' and 'pro:pro'
PR Requirements
- PR title must follow Conventional Commits specification
- For most PRs, you only need to run linting and type checking
- E2E tests will only run if PR has "ready-for-e2e" label
Logging
- Control logging verbosity by setting `NEXT_PUBLIC_LOGGER_LEVEL` in .env:
- 0: silly
- 1: trace
- 2: debug
- 3: info
- 4: warn
- 5: error
- 6: fatal
## When addressing issues in the Cal.com repository
When working on the Cal.com repository, prioritize fixing type issues before addressing failing tests. Running `yarn type-check:ci --force` to identify and fix TypeScript errors should be done first, as these errors are often the root cause of test failures. Only after resolving type issues should you move on to fixing failing tests with `TZ=UTC yarn test`.
## When creating pull requests
Create pull requests in draft mode by default, so that actual human can mark it as ready for review only when it is.
## When developing Playwright tests in the Cal.com repository
Always ensure Playwright tests pass locally before pushing code. The user requires fast local e2e feedback loops instead of relying on CI, which is too slow for development iteration. Never push test code until those tests are passing locally first.
## When fixing failing tests in the Cal.com repository
When fixing failing tests in the Cal.com repository, take an incremental approach by addressing one file at a time rather than attempting to fix all issues simultaneously. This methodical approach makes it easier to identify and resolve specific issues without getting overwhelmed by the complexity of multiple failing tests across different files. Focus on getting each file's tests passing completely before moving on to the next file.
To identify and fix issues in the Cal.com codebase:
1. Run `yarn type-check:ci --force` to identify TypeScript type errors and get fresh results always, bypassing any caching issues
2. Run `yarn test` to identify failing unit tests
3. Address both type errors and failing tests before considering the task complete
4. Type errors often need to be fixed first as they may be causing the test failures
## When implementing mock services in the Cal.com test files
When mocking calendar services in Cal.com test files, implement the Calendar interface rather than adding individual properties from each specific calendar service type (like FeishuCalendarService). Since all calendar services implement the Calendar interface and are stored in a map, the mock service should also implement this interface to ensure type compatibility. This approach is more maintainable than trying to add all properties from each specific calendar service implementation. For complex mocks that cause type compatibility issues with deep mocks, consider using simpler fake implementations that directly implement the required interfaces instead of trying to match all properties of the original service classes. When needed, you can modify other mock files to support your implementation rather than trying to force compatibility with existing mocks.
## When implementing mocks for Cal.com app-store integrations
When mocking app-store resources in Cal.com tests, prefer implementing simpler mock designs that directly implement the required interfaces rather than trying to match complex deep mock structures created with mockDeep. This approach is more maintainable and helps resolve type compatibility issues. The user encourages creative solutions and refactoring to better designs when the standard mocking approach causes persistent type errors.
## When implementing workflow triggers in the Cal.com repository
To trigger workflows in Cal.com, use the `scheduleWorkflowReminders` function. This is the standard approach used throughout the codebase for triggering any workflow. Before implementing new workflow triggers, examine existing implementations in the codebase to understand the pattern. The function filters workflows by trigger type and processes each workflow step. Key locations where this is used include booking handlers, confirmation processes, and other booking-related events.
## When making changes to Prisma schema in Cal.com
After making changes to the Prisma schema in Cal.com and creating migrations, you need to run `yarn prisma generate` to update the TypeScript types. This is especially important when switching Node.js versions, after adding new fields to models, or after pulling changes that include Prisma schema updates, as it ensures the TypeScript compiler recognizes the updated schema structure. If you encounter errors related to missing enum values (like CreationSource.WEBAPP), running `yarn prisma generate` will typically resolve these issues by regenerating the TypeScript types from the schema.
If you encounter enum generator errors during the Prisma generate step (like "Cannot find module './enum-generator.ts'"), run `yarn install` first before trying to generate. This ensures all dependencies are properly installed before the generation process.
Whenever you change the schema.prisma file, remember to always consolidate migrations by squashing them as declared in the Prisma docs: https://www.prisma.io/docs/orm/prisma-migrate/workflows/squashing-migrations. This helps maintain a clean migration history and prevents accumulation of multiple migration files.
## When reviewing PRs in the Cal.com repository
When asked to review a PR, focus on providing a clear summary of what the PR is doing and its core functionality. Avoid getting sidetracked by CI failures, testing issues, or technical implementation details unless specifically requested. The user prefers concise, focused reviews that prioritize understanding the main purpose and changes of the PR.
## When running Playwright tests in the Cal.com repository
Use the command format `PLAYWRIGHT_HEADLESS=1 yarn e2e [test-file.e2e.ts]` to run Playwright tests instead of the standard `yarn playwright test` command. This format includes the proper timezone setting, virtual display server, and uses the repository's e2e runner.
## When running tests in the Cal.com repository
When running tests in the Cal.com repository, use the TZ=UTC environment variable (e.g., `TZ=UTC yarn test`) to ensure consistent timezone handling. This prevents timezone-related test failures that might occur when tests are run in different environments or by different developers with varying local timezone settings.
## When working on calendar cache features in the Cal.com codebase
The calendar cache system follows specific patterns in `packages/features/calendar-cache-sql`. When implementing provider-specific calendar cache services (like for Outlook/Office365), the provider-specific code should be placed in the corresponding provider directory (e.g., `packages/app-store/office365calendar`).
## When working on PRs in the Cal.com repository
When making changes to the Cal.com codebase, always run type checks locally using `yarn type-check:ci` before concluding that CI failures are unrelated to your changes. Even if errors appear in files you haven't directly modified, your changes might still be causing type issues through dependencies or type inference. Compare type check results between the main branch and your feature branch to confirm whether you've introduced new type errors.
## When working on type issues in the Cal.com repository
Type casting with "as any" is strictly forbidden in the Cal.com codebase. When encountering Prisma type incompatibilities or other TypeScript type issues, proper type-safe solutions must be used instead, such as Prisma extensions system, type parameter constraints, repository pattern isolation, explicit type definitions, and extension composition patterns that are already established in the codebase.
## When working on UI layout changes in the Cal.com web application
The event types page UI components are located in `apps/web/modules/event-types/views/event-types-listing-view.tsx`. This file contains the layout implementation for the search bar and team tabs components on the event types listing page.
Changes to shared UI patterns (like tab layouts and button alignments) need to be checked across multiple views to maintain consistency:
- Event types page layout: `apps/web/modules/event-types/views/event-types-listing-view.tsx`
- Bookings page layout: `apps/web/modules/bookings/views/bookings-listing-view.tsx`
- Common elements like tabs, search bars, and filter buttons should maintain consistent alignment across views
## When working on workflow triggers or similar enum-based features in the Cal.com codebase
To add new workflow triggers:
1. Check packages/prisma/schema.prisma for existing webhooks and workflow trigger enums as reference
2. Add the same enums to workflows (only when asked by user specifically, or else focus on users requirement)
3. Add enums to packages/features/ee/workflows/lib/constants.ts for UI display
4. Add translations to en/locale.json using the format {enum}_trigger (all lowercase). Webhook triggers serve as the reference implementation pattern for workflow triggers.
## When working with app-store integrations in Cal.com
The Cal.com repository uses generated files (*.generated.ts) for app-store integrations. These files are created by the app-store-cli tool. When making structural changes to how integrations are imported or used, you need to update CLI code that generates these files. Typically manual changes to the *.generated.ts files are not made. That was only for PR 19771 for a proof-of-concept. Recent changes have moved from dynamic imports to static map-based imports for better performance. When working with browser components in the app-store, static imports should be used rather than dynamic imports (using Next.js' dynamic function) to maintain consistency with the performance improvements.
When modifying the app-store-cli build.ts file, you must ensure it correctly handles all types of generated files:
1. Regular service files (calendar.services.generated.ts, crm.services.generated.ts, etc.) need default imports
2. Browser component files (apps.browser-addon.generated.tsx, etc.) may require dynamic imports with Next.js
3. After making changes to build.ts, always verify all generated files have the correct imports by checking each file type
The lazyImport parameter in getExportedObject() determines whether to use dynamic imports (for browser components) or static imports (for server-side services).
## When working with branches in the Cal.com repository
When asked to move changes to a different branch in the Cal.com repository, use git commands to commit existing changes to the specified branch rather than redoing the work. This is more efficient and prevents duplication of effort. The user prefers direct branch operations over reimplementing the same changes multiple times.
## When working with calendar events in the Cal.com codebase
Cal.com events in Google Calendar can be identified by checking if the iCalUID ends with "@Cal.com" (e.g., "2GBXSdEixretciJfKVmYN8@Cal.com"). This identifier is used to distinguish Cal.com bookings from other calendar events for data storage and privacy purposes.
## When working with CI/CD in the Cal.com repository
When reviewing CI check failures in Cal.com:
1. E2E tests can be flaky and may fail intermittently
2. Focus only on CI failures that are directly related to your code changes
3. Infrastructure-related failures (like dependency installation issues) can be disregarded if all code-specific checks (type checking, linting, unit tests) are passing
## When working with database models in Cal.com
Database models in Cal.com are defined in `packages/prisma/schema.prisma`. When adding new fields to models:
1. For timestamp fields like `createdAt` and `updatedAt`:
- Do not set default values if you want existing records to have null values
- Only new records should get timestamps automatically
- For `updatedAt` fields, ensure they're updated when records are modified
2. Create a migration using `npx prisma migrate dev --name migration_name` to update the database schema
3. When implementing cache-related features that require timestamp tracking, always update the database schema first before modifying application code that references those fields. The schema changes must be completed and migrated before the application can successfully query or use the new fields.
## When working with generated files in the Cal.com repository
When fixing imports in Cal.com's generated files (like packages/app-store/apps.browser-*.generated.tsx), always check the actual exports in the source files first. For EventTypeAppCardInterface components, they likely use named exports rather than default exports, requiring imports like `import * as ComponentName from "./path"` instead of `import ComponentName from "./path"`. This verification step is crucial when working with browser-addon, browser-appsettings, browser-eventtypesettings, and browser-install generated files. For these files, if you're seeing import errors, check whether the components are exported as default exports in their source files and adjust your import statements accordingly.
## When working with git and CI systems
Always push committed changes to the remote repository before waiting for or checking CI status. Waiting for CI checks on unpushed local commits is backwards - the CI runs on the remote repository state, not local commits. The proper sequence is: commit locally, run local checks, push to remote, then monitor CI status.
## When working with imports/exports in the Cal.com codebase
When working with imports in the Cal.com codebase, particularly in app-store integrations, pay attention to whether modules use named exports or default exports. Many services like VideoApiAdapter, CalendarService, and PaymentService are exported as named exports, but the actual export name may differ from the generic service type (e.g., `export class AppleCalendarService` instead of `export class CalendarService`). When importing these services, verify the actual export name in the source file and use the appropriate named import syntax (e.g., `import { AppleCalendarService } from "./applecalendar/lib/CalendarService"` or with renaming `import { AppleCalendarService as ApplecalendarCalendarService } from "./applecalendar/lib/CalendarService"`). Always check the actual export pattern and name in the source file to determine the correct import syntax.
## When working with tRPC components in the Cal.com repository
When making changes that affect tRPC components or after pulling updates that modify tRPC-related files, you need to follow a specific build order:
1. First run `yarn prisma generate` to ensure all database types are up-to-date
2. Then run `cd packages/trpc && yarn build` to rebuild the tRPC package
This sequence ensures that type definitions are properly generated before building the tRPC components that depend on them, preventing type errors during compilation.
## When working with workflows or webhooks in the Cal.com repository
Workflows and webhooks are two completely separate features in Cal.com with different implementations and file structures. The workflow constants file is located at `packages/features/ee/workflows/lib/constants.ts`, not in the webhooks directory. When working on workflow triggers, do not reference or use webhook trigger implementations - they are distinct systems that should not be confused or mixed.
## When adding new feature flags to the Cal.com repository
To seed new feature flags in Cal.com, create a Prisma migration using the command `yarn prisma migrate dev --create-only --name seed_[feature_name]_feature`. The migration file should be placed in `packages/prisma/migrations/` with a timestamp prefix format like `20250724210733_seed_calendar_cache_sql_features/migration.sql`. Follow the pattern from existing feature seeding migrations like `packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql` for the SQL structure. The migration should INSERT the new features into the `Feature` table with appropriate type (like `OPERATIONAL`) and default `enabled` status for manual team enablement.
## When adding new UI elements or text strings to Cal.com
All UI strings in Cal.com must be properly translated using the i18n system. This includes:
- Labels for new UI elements (like dropdown labels, settings headers)
- Option values that are displayed to users
- Any text that appears in the interface
Even if some related strings are already translated (like "Bookings" and "Insights"), new strings must be explicitly added to the translation system.
## When creating a pull request
### Title
- Use conventional commits: `feat:`, `fix:`, `refactor:`
- Be specific: `fix: handle timezone edge case in booking creation`
- Not generic: `fix: booking bug`
### Size Limits
- **Large PRs** (>500 lines or >10 files) are not recommended.
- Guide the user how to split large PRs into smaller ones.
## When handling errors
### Descriptive Errors
```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");
```
### Error Types
```typescript
// ✅ Good - Use proper error classes
import { TRPCError } from "@trpc/server";
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid booking time slot",
});
```
## Basic Performance Guidelines
- Aim for O(n) or O(n log n) complexity, avoid O(n²)
- Use database-level filtering instead of JavaScript filtering
- Consider pagination for large datasets
- Use database transactions for related operations
## When querying database
Prefer Select over Include:
```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
const booking = await prisma.booking.findFirst({
include: {
user: true, // This gets ALL user fields
}
});
```
## Avoid barrel imports
```typescript
// ❌ Bad - Avoid importing from index.ts barrel files
import { BookingService, UserService } from "./services";
// ✅ Good - Import directly from source files
import { BookingService } from "./services/BookingService";
import { UserService } from "./services/UserService";
// ❌ Bad
import { Button } from "@calcom/ui";
// ✅ Good - Import directly from source files
import { Button } from "@calcom/ui/components/button";
```
## File Naming Conventions
### Repository Files
- **Must** include `Repository` suffix, PascalCase matching class: `PrismaBookingRepository.ts`
### Service Files
- **Must** include `Service` suffix, PascalCase matching class, avoid generic names: `MembershipService.ts`
### General Files
- **Components**: PascalCase (e.g., `BookingForm.tsx`)
- **Utilities**: kebab-case (e.g., `date-utils.ts`)
- **Types**: PascalCase with `.types.ts` suffix (e.g., `Booking.types.ts`)
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
## When using Day.js
```typescript
// ⚠️ Slow in performance-critical code (loops)
dates.map((date) => dayjs(date).add(1, "day").format());
// ✅ Better - Use .utc() for performance
dates.map((date) => dayjs.utc(date).add(1, "day").format());
// ✅ Best - Use native Date when possible
dates.map((date) => new Date(date.valueOf() + 24 * 60 * 60 * 1000));
```
+14 -1
View File
@@ -383,10 +383,23 @@ TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# Used for Cal.ai Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Used for buying phone number for cal ai voice agent
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=
CAL_AI_CALL_RATE_PER_MINUTE=0.29
STRIPE_WEBHOOK_SECRET_BILLING=
# Price for buying a phone number for cal.ai voice agent (Default is 5)
NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE=
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
+18
View File
@@ -2,4 +2,22 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
plugins: ["import"],
rules: {
"import/no-cycle": ["warn", { maxDepth: Infinity }],
},
overrides: [
{
files: ["packages/lib/**/*.{ts,tsx,js,jsx}", "packages/prisma/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
paths: ["@calcom/app-store"],
patterns: ["@calcom/app-store/*"],
},
],
},
},
],
};
+7 -2
View File
@@ -2,7 +2,12 @@
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/lib/csp.ts @calcom/Foundation
/apps/web/lib/buildNonce.ts @calcom/Foundation
/apps/web/lib/daily-webhook/**/* @calcom/Foundation
/apps/web/lib/core/**/* @calcom/Foundation
/apps/web/lib/booking.ts @calcom/Foundation
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation
@@ -25,7 +30,7 @@
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/DBA
/packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
working-directory: docs
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
npm install -g mintlify@4.0.436
npm install -g mintlify@4.2.87
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
+3 -3
View File
@@ -1,4 +1,4 @@
name: Atoms E2E Tests
name: E2E Atoms
on:
workflow_call:
@@ -19,7 +19,7 @@ env:
jobs:
e2e-atoms:
timeout-minutes: 15
name: Atoms E2E Tests
name: E2E Atoms
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- uses: docker/login-action@v3
@@ -30,7 +30,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- name: Run Atoms E2E Tests
- name: Run E2E Atoms Tests
working-directory: packages/platform/examples/base
run: yarn test:e2e
- name: Upload Test Results
+4
View File
@@ -97,3 +97,7 @@ apps/auth
!.yarn/sdks
!.yarn/versions
i18n.cache
packages/**/.yarn/ci-cache/
# AI
.claude
View File
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+20
View File
@@ -0,0 +1,20 @@
# Cal.com Development Guide for AI Agents
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
## Quick Reference
### Essential Commands
- `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
## 📚 Detailed Documentation
- **[.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
@@ -90,7 +90,7 @@ function getBaseProperties(field: InputBookingField): CustomField | SystemField
}
if (fieldIsCustomSystemName(field)) {
const systemName = { ...systemBeforeFieldName };
const systemName = structuredClone(systemBeforeFieldName);
if (systemName.variantsConfig?.variants?.fullName?.fields?.[0]) {
systemName.variantsConfig.variants.fullName.fields[0].label = field.label;
}
@@ -110,7 +110,7 @@ function getBaseProperties(field: InputBookingField): CustomField | SystemField
}
if (fieldIsCustomSystemNameSplit(field)) {
const systemNameSplit = { ...systemBeforeFieldNameSplit };
const systemNameSplit = structuredClone(systemBeforeFieldNameSplit);
const firstNameField = systemNameSplit.variantsConfig?.variants?.firstAndLastName?.fields?.find(
(field) => field.name === "firstName"
@@ -520,8 +520,7 @@ export const systemBeforeFieldName: NameSystemField = {
name: "fullName",
type: "text",
required: true,
label: "",
placeholder: "",
label: "your_name",
},
],
},
@@ -292,8 +292,8 @@ describe("transformBookingFieldsInternalToApi", () => {
isDefault: true,
required: true,
disableOnPrefill: false,
label: "",
placeholder: "",
label: "your_name",
placeholder: undefined,
},
];
@@ -2,13 +2,7 @@ import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
import {
PrismaClientInitializationError,
PrismaClientKnownRequestError,
PrismaClientRustPanicError,
PrismaClientUnknownRequestError,
PrismaClientValidationError,
} from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import {
@@ -21,18 +15,18 @@ import {
import { Response } from "@calcom/platform-types";
type PrismaError =
| PrismaClientInitializationError
| PrismaClientKnownRequestError
| PrismaClientRustPanicError
| PrismaClientUnknownRequestError
| PrismaClientValidationError;
| Prisma.PrismaClientInitializationError
| Prisma.PrismaClientKnownRequestError
| Prisma.PrismaClientRustPanicError
| Prisma.PrismaClientUnknownRequestError
| Prisma.PrismaClientValidationError;
@Catch(
PrismaClientInitializationError,
PrismaClientKnownRequestError,
PrismaClientRustPanicError,
PrismaClientUnknownRequestError,
PrismaClientValidationError
Prisma.PrismaClientInitializationError,
Prisma.PrismaClientKnownRequestError,
Prisma.PrismaClientRustPanicError,
Prisma.PrismaClientUnknownRequestError,
Prisma.PrismaClientValidationError
)
export class PrismaExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("PrismaExceptionFilter");
@@ -58,7 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
let message = "There was an error, please try again later.";
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode = INTERNAL_SERVER_ERROR;
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case "P2002": // Unique constraint failed
errorCode = CONFLICT;
@@ -107,6 +107,10 @@ export class BillingService implements OnModuleDestroy {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
if (!customerId) {
throw new NotFoundException("No customer id associated with the team.");
}
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
success_url: `${this.webAppUrl}/settings/platform/`,
@@ -116,6 +120,7 @@ export class BillingService implements OnModuleDestroy {
teamId: teamId.toString(),
plan: plan.toString(),
},
currency: "usd",
});
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
@@ -725,8 +725,7 @@ describe("Organizations Event Types Endpoints", () => {
isDefault: true,
type: "name",
slug: "name",
label: "",
placeholder: "",
label: "your_name",
required: true,
disableOnPrefill: false,
},
@@ -823,9 +822,8 @@ describe("Organizations Event Types Endpoints", () => {
isDefault: true,
type: "name",
slug: "name",
label: "",
placeholder: "",
required: true,
label: "your_name",
disableOnPrefill: false,
},
{
@@ -181,14 +181,18 @@ const organizationAdminKeys = [
"delegation_credential",
];
export interface SettingsPermissions {
canViewRoles?: boolean;
}
const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
permissions,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
@@ -227,7 +231,7 @@ const useTabs = ({
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
if (isPbacEnabled && canViewRoles) {
if (isPbacEnabled && permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
@@ -294,7 +298,7 @@ interface SettingsSidebarContainerProps {
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
}
const TeamRolesNavItem = ({
@@ -487,7 +491,7 @@ const SettingsSidebarContainer = ({
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
canViewRoles,
permissions,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
@@ -517,7 +521,7 @@ const SettingsSidebarContainer = ({
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
permissions,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -792,13 +796,13 @@ export type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({
children,
teamFeatures,
canViewRoles,
permissions,
...rest
}: SettingsLayoutProps) {
const pathname = usePathname();
@@ -833,7 +837,7 @@ export default function SettingsLayoutAppDirClient({
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
permissions={permissions}
/>
}
drawerState={state}
@@ -856,7 +860,7 @@ type SidebarContainerElementProps = {
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
};
const SidebarContainerElement = ({
@@ -864,7 +868,7 @@ const SidebarContainerElement = ({
bannersHeight,
setSideContainerOpen,
teamFeatures,
canViewRoles,
permissions,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -881,7 +885,7 @@ const SidebarContainerElement = ({
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
permissions={permissions}
/>
</>
);
@@ -67,7 +67,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
return (
<>
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles }}
/>
</>
);
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "@calcom/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -1,6 +1,9 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { ConferencingAppsViewWebWrapper } from "@calcom/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -12,7 +15,24 @@ export const generateMetadata = async () =>
);
const Page = async () => {
return <ConferencingAppsViewWebWrapper />;
const [appsCaller, eventTypesCaller] = await Promise.all([
createRouterCaller(appsRouter),
createRouterCaller(eventTypesRouter),
]);
const [integrations, defaultConferencingApp, eventTypesQueryData] = await Promise.all([
appsCaller.integrations({ variant: "conferencing", onlyInstalled: true }),
appsCaller.getUsersDefaultConferencingApp(),
eventTypesCaller.bulkEventFetch(),
]);
return (
<ConferencingAppsViewWebWrapper
integrations={integrations}
defaultConferencingApp={defaultConferencingApp}
eventTypes={eventTypesQueryData?.eventTypes ?? []}
/>
);
};
export default Page;
@@ -3,7 +3,11 @@
import { useState } from "react";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import {
Scope,
CrudAction,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Checkbox, Label } from "@calcom/ui/components/form";
@@ -17,6 +21,7 @@ interface AdvancedPermissionGroupProps {
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -26,21 +31,31 @@ export function AdvancedPermissionGroup({
selectedPermissions,
onChange,
disabled,
scope = Scope.Organization,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
const resourceConfig = PERMISSION_REGISTRY[resource];
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);
const isAllResources = resource === "*";
// Early return if resource is not in the scoped registry (and not the special "*" resource)
if (!isAllResources && !resourceConfig) {
return null;
}
const allResourcesSelected = selectedPermissions.includes("*.*");
// Get all possible permissions for this resource
const allPermissions = isAllResources
? ["*.*"]
: Object.entries(resourceConfig)
: resourceConfig
? Object.entries(resourceConfig)
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
.map(([action]) => `${resource}.${action}`);
.map(([action]) => `${resource}.${action}`)
: [];
// Check if all permissions for this resource are selected
const isAllSelected = isAllResources
@@ -99,7 +114,7 @@ export function AdvancedPermissionGroup({
<span
className="text-default cursor-pointer text-sm font-medium leading-none"
onClick={() => setIsExpanded(!isExpanded)}>
{t(resourceConfig._resource?.i18nKey || "")}
{t(resourceConfig?._resource?.i18nKey || "")}
</span>
<span
className="text-muted cursor-pointer text-sm font-medium leading-none"
@@ -113,56 +128,57 @@ export function AdvancedPermissionGroup({
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
>
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;
{resourceConfig &&
Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;
if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}
if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}
const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);
const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);
return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
)}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</span>
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</div>
</div>
</div>
);
})}{" "}
);
})}{" "}
</div>
)}
</div>
@@ -145,10 +145,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
});
const onSubmit = (values: FormValues) => {
// Store the color in localStorage
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
localStorage.setItem(`role_color_${roleKey}`, values.color);
if (isEditing && role) {
updateMutation.mutate({
teamId,
@@ -224,6 +220,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
/>
))}
</div>
@@ -247,6 +244,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
/>
))}
</div>
@@ -1,6 +1,6 @@
"use client";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
@@ -13,6 +13,7 @@ interface SimplePermissionItemProps {
permissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
}
export function SimplePermissionItem({
@@ -20,9 +21,10 @@ export function SimplePermissionItem({
permissions,
onChange,
disabled,
scope,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const isAllResources = resource === "*";
const options = isAllResources
@@ -1,5 +1,12 @@
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import {
PERMISSION_REGISTRY,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import {
getTransitiveDependencies,
getTransitiveDependents,
} from "@calcom/features/pbac/utils/permissionTraversal";
export type PermissionLevel = "none" | "read" | "all";
@@ -14,10 +21,11 @@ interface UsePermissionsReturn {
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
}
export function usePermissions(): UsePermissionsReturn {
export function usePermissions(scope: Scope = Scope.Organization): UsePermissionsReturn {
const getAllPossiblePermissions = () => {
const permissions: string[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
const scopedRegistry = getPermissionsForScope(scope);
Object.entries(scopedRegistry).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config)
.filter((action) => !action.startsWith("_"))
@@ -30,7 +38,8 @@ export function usePermissions(): UsePermissionsReturn {
};
const hasAllPermissions = (permissions: string[]) => {
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
const scopedRegistry = getPermissionsForScope(scope);
return Object.entries(scopedRegistry).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config)
.filter((action) => !action.startsWith("_"))
@@ -81,7 +90,8 @@ export function usePermissions(): UsePermissionsReturn {
} else {
// Filter out current resource permissions
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
const scopedRegistry = getPermissionsForScope(scope);
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
if (!resourceConfig) return currentPermissions;
@@ -129,34 +139,22 @@ export function usePermissions(): UsePermissionsReturn {
// Add the requested permission
newPermissions.push(permission);
// If enabling create, update, or delete, automatically enable read permission
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
const readPermission = `${resource}.${CrudAction.Read}`;
if (!newPermissions.includes(readPermission)) {
newPermissions.push(readPermission);
// Add all transitive dependencies
const dependencies = getTransitiveDependencies(permission);
dependencies.forEach((dependency) => {
if (!newPermissions.includes(dependency)) {
newPermissions.push(dependency);
}
}
});
} else {
// When disabling a permission, check if we need to disable related permissions
if (action === CrudAction.Read) {
// If disabling read, also disable create, update, and delete since they depend on read
const dependentActions = [CrudAction.Create, CrudAction.Update, CrudAction.Delete];
dependentActions.forEach((dependentAction) => {
const dependentPermission = `${resource}.${dependentAction}`;
newPermissions = newPermissions.filter((p) => p !== dependentPermission);
});
} else if (
action === CrudAction.Create ||
action === CrudAction.Update ||
action === CrudAction.Delete
) {
// If disabling create, update, or delete, just remove that specific permission
// Read permission remains enabled
newPermissions = newPermissions.filter((p) => p !== permission);
} else {
// For other actions (custom actions), just remove the specific permission
newPermissions = newPermissions.filter((p) => p !== permission);
}
// When disabling a permission, first remove the permission itself
newPermissions = newPermissions.filter((p) => p !== permission);
// Remove all transitive dependents
const dependents = getTransitiveDependents(permission);
dependents.forEach((dependent) => {
newPermissions = newPermissions.filter((p) => p !== dependent);
});
}
// Only add *.* back if all permissions are now selected
@@ -1,13 +1,21 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamMembersView } from "~/teams/team-members-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
@@ -54,6 +62,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const teamId = parseInt(id);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id) {
return redirect("/auth/login");
}
const teamCaller = await createRouterCaller(viewerTeamsRouter);
const team = await teamCaller.get({ teamId });
@@ -70,6 +84,54 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
getCachedTeamAttributes(organizationId),
]);
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
// If the team is not private we allow members to list other members
if (!team.isPrivate) {
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
}
// Get specific PBAC permissions for team member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: teamId,
resource: Resource.Team,
userRole: team.membership.role,
actions: [
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembers]: {
roles: fallbackRolesCanListMembers,
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
};
const facetedTeamValues = {
roles,
teams: [team],
@@ -84,7 +146,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
return (
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
<TeamMembersView
team={team}
facetedTeamValues={facetedTeamValues}
attributes={attributes}
permissions={memberPermissions}
/>
</SettingsHeader>
);
};
@@ -1,12 +1,20 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { MembersView } from "~/members/members-view";
export const generateMetadata = async () =>
@@ -38,10 +46,63 @@ const getCachedRoles = unstable_cache(
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
const fallbackRolesThatCanSeeMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
if (!org?.isPrivate) {
fallbackRolesThatCanSeeMembers.push(MembershipRole.MEMBER);
}
// Get specific PBAC permissions for organization member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
};
const facetedTeamValues = {
roles,
teams,
@@ -55,7 +116,13 @@ const Page = async () => {
};
return (
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
<MembersView
org={org}
teams={teams}
facetedTeamValues={facetedTeamValues}
attributes={attributes}
permissions={memberPermissions}
/>
);
};
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import handler from "@calcom/features/calAIPhone/phoneNumberSubscriptionWebhook";
export const GET = defaultResponderForAppDir(handler);
+10 -2
View File
@@ -5,7 +5,7 @@ import type { NextRequest } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { bookingCancelInput } from "@calcom/prisma/zod-utils";
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -16,8 +16,16 @@ async function handler(req: NextRequest) {
} catch (error) {
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
}
const bookingData = bookingCancelWithCsrfSchema.parse(appDirRequestBody);
const cookieStore = await cookies();
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
if (!cookieToken || cookieToken !== bookingData.csrfToken) {
return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 });
}
cookieStore.delete("calcom.csrf_token");
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const bookingData = bookingCancelInput.parse(appDirRequestBody);
const result = await handleCancelBooking({
bookingData,
userId: session?.user?.id || -1,
+17
View File
@@ -0,0 +1,17 @@
import { randomBytes } from "crypto";
import { NextResponse } from "next/server";
export async function GET() {
const token = randomBytes(32).toString("hex");
const res = NextResponse.json({ csrfToken: token });
res.cookies.set("calcom.csrf_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
return res;
}
-13
View File
@@ -1,13 +0,0 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { NextResponse } from "next/server";
async function getHandler() {
return NextResponse.json({ message: "Please don't" }, { status: 400 });
}
async function postHandler() {
return NextResponse.json({ message: "Please don't" }, { status: 400 });
}
export const GET = defaultResponderForAppDir(getHandler);
export const POST = defaultResponderForAppDir(postHandler);
@@ -0,0 +1,686 @@
import type { NextRequest } from "next/server";
import { Retell } from "retell-sdk";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import type { CalAiPhoneNumber, User, Team } from "@calcom/prisma/client";
import { POST } from "../route";
type MockPhoneNumberWithUser = Omit<CalAiPhoneNumber, "user" | "team"> & {
user: Pick<User, "id" | "email" | "name">;
team: null;
};
type MockPhoneNumberWithTeam = Omit<CalAiPhoneNumber, "user" | "team"> & {
user: null;
team: Pick<Team, "id" | "name">;
};
type RetellWebhookBody = {
event: "call_started" | "call_ended" | "call_analyzed";
call: {
call_id: string;
agent_id?: string;
from_number?: string;
to_number?: string;
direction?: "inbound" | "outbound";
call_status?: string;
start_timestamp?: number;
end_timestamp?: number;
disconnection_reason?: string;
metadata?: Record<string, unknown>;
retell_llm_dynamic_variables?: Record<string, unknown>;
transcript?: string;
opt_out_sensitive_data_storage?: boolean;
call_cost?: {
product_costs?: Array<{
product: string;
unitPrice?: number;
cost?: number;
}>;
total_duration_seconds?: number;
total_duration_unit_price?: number;
total_one_time_price?: number;
combined_cost?: number;
};
call_analysis?: {
call_summary?: string;
in_voicemail?: boolean;
user_sentiment?: string;
call_successful?: boolean;
custom_analysis_data?: Record<string, unknown>;
};
};
};
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
(
handler: (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<Response>
) =>
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) =>
await handler(req, context || { params: Promise.resolve({}) }),
}));
vi.mock("retell-sdk", () => ({
Retell: {
verify: vi.fn(),
},
}));
const mockHasAvailableCredits = vi.fn();
const mockChargeCredits = vi.fn();
vi.mock("@calcom/features/ee/billing/credit-service", () => ({
CreditService: vi.fn().mockImplementation(() => ({
hasAvailableCredits: mockHasAvailableCredits,
chargeCredits: mockChargeCredits,
})),
}));
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
PrismaPhoneNumberRepository: {
findByPhoneNumber: vi.fn(),
},
}));
vi.mock("next/server", () => ({
NextResponse: {
json: vi.fn((data, options) => ({
json: () => Promise.resolve(data),
status: options?.status || 200,
})),
},
}));
const createMockRequest = (body: RetellWebhookBody, signature?: string): NextRequest => {
const request = {
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
headers: {
get: vi.fn((name: string) => (name === "x-retell-signature" ? signature : null)),
},
} as unknown as NextRequest;
return request;
};
const callPOST = (request: NextRequest) => POST(request, { params: Promise.resolve({}) });
describe("Retell AI Webhook Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("RETELL_AI_KEY", "test-api-key");
vi.stubEnv("CAL_AI_CALL_RATE_PER_MINUTE", "0.29");
});
it("should return 401 when signature is missing", async () => {
const request = createMockRequest(
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
"some-signature"
);
const response = await callPOST(request);
expect(response.status).toBe(401);
});
it("should return 401 when API key is not configured", async () => {
vi.unstubAllEnvs();
const request = createMockRequest(
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
"some-signature"
);
const response = await callPOST(request);
expect(response.status).toBe(401);
// Restore the stubbed env for subsequent tests
vi.stubEnv("RETELL_AI_KEY", "test-api-key");
});
it("should return 401 when signature is invalid", async () => {
vi.mocked(Retell.verify).mockReturnValue(false);
const request = createMockRequest(
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
"invalid-signature"
);
const response = await callPOST(request);
expect(response.status).toBe(401);
});
it("should handle non-call_analyzed events", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const body: RetellWebhookBody = {
event: "call_started",
call: { call_id: "test-call-id" },
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.message).toContain("No handling for call_started");
});
it("should process call_analyzed event with valid phone number and sufficient credits", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 120,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
teamId: undefined,
credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits
callDuration: 120,
externalRef: "retell:test-call-id",
})
);
});
it("should handle team phone numbers", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockTeamPhoneNumber: MockPhoneNumberWithTeam = {
id: 2,
phoneNumber: "+1234567890",
userId: null,
teamId: 5,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id-2",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
team: { id: 5, name: "Test Team" },
user: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 50,
total_duration_seconds: 180,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: undefined,
teamId: 5,
credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits
callDuration: 180,
externalRef: "retell:test-call-id",
})
);
});
it("should handle missing total_duration_seconds", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
// missing total_duration_seconds
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
});
it("should handle phone number not found", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(null);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 90,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
expect(mockChargeCredits).not.toHaveBeenCalled();
});
it("should handle insufficient credits", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 3,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id-3",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(false);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 150,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
});
it("should handle schema validation errors", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
// Missing required fields like from_number, to_number, etc.
call_id: "test-call-id",
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.error).toBe("Internal server error");
});
it("should calculate credits correctly based on duration", async () => {
const testCases = [
{ durationSeconds: 60, expectedCredits: 29 }, // 1 minute * $0.29 = $0.29 = 29 credits
{ durationSeconds: 120, expectedCredits: 58 }, // 2 minutes * $0.29 = $0.58 = 58 credits
{ durationSeconds: 150, expectedCredits: 73 }, // 2.5 minutes * $0.29 = $0.725 = 73 credits (rounded up)
{ durationSeconds: 30, expectedCredits: 15 }, // 0.5 minutes * $0.29 = $0.145 = 15 credits (rounded up)
];
for (const { durationSeconds, expectedCredits } of testCases) {
vi.clearAllMocks();
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 4,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id-4",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: `test-call-${durationSeconds}s`,
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100, // This is ignored now
total_duration_seconds: durationSeconds,
},
},
};
const request = createMockRequest(body, "valid-signature");
await callPOST(request);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
teamId: undefined,
credits: expectedCredits,
callDuration: durationSeconds,
externalRef: expect.stringMatching(/^retell:test-call-/),
})
);
}
});
it("should pass callDuration to chargeCredits when provided", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 10,
phoneNumber: "+15550001111",
userId: 42,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "prov-10",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 42, email: "u@example.com", name: "U" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "call-dur",
from_number: "+15550002222",
to_number: "+15550001111",
direction: "outbound",
call_status: "completed",
start_timestamp: 123,
call_cost: {
combined_cost: 12, // This is ignored now
total_duration_seconds: 125,
},
},
};
const response = await callPOST(createMockRequest(body, "valid-signature"));
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, credits: 61, callDuration: 125 }) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
);
});
describe("Idempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should pass externalRef with correct format to chargeCredits", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockChargeCredits.mockResolvedValue({ userId: 1 });
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-idempotency-call",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 60,
},
},
};
const response = await callPOST(createMockRequest(body, "valid-signature"));
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
teamId: undefined,
credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits
callDuration: 60,
externalRef: "retell:test-idempotency-call",
})
);
});
it("should handle duplicate webhook calls idempotently", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "duplicate-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 60,
},
},
};
// First call - should charge normally
mockChargeCredits.mockResolvedValue({ userId: 1 });
const response1 = await callPOST(createMockRequest(body, "valid-signature"));
expect(response1.status).toBe(200);
const data1 = await response1.json();
expect(data1.success).toBe(true);
expect(data1.message).toContain("Successfully charged 29 credits");
// Second call - should return duplicate
mockChargeCredits.mockResolvedValue({ bookingUid: null, duplicate: true });
const response2 = await callPOST(createMockRequest(body, "valid-signature"));
expect(response2.status).toBe(200);
const data2 = await response2.json();
expect(data2.success).toBe(true);
expect(data2.message).toContain("Successfully charged 29 credits");
// Verify chargeCredits was called twice with same externalRef
expect(mockChargeCredits).toHaveBeenCalledTimes(2);
expect(mockChargeCredits).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ externalRef: "retell:duplicate-call-id" })
);
expect(mockChargeCredits).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ externalRef: "retell:duplicate-call-id" })
);
});
it("should handle errors from chargeCredits gracefully", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
// Mock chargeCredits to throw an error
mockChargeCredits.mockRejectedValue(new Error("Credit service error"));
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "error-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 60,
},
},
};
const response = await callPOST(createMockRequest(body, "valid-signature"));
// Should still return 200 to prevent retries
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toContain("Error charging credits for Retell AI call");
});
});
});
@@ -0,0 +1,229 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { Retell } from "retell-sdk";
import { z } from "zod";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import { CreditUsageType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
const RetellWebhookSchema = z.object({
event: z.enum(["call_started", "call_ended", "call_analyzed"]),
call: z
.object({
call_id: z.string(),
agent_id: z.string().optional(),
from_number: z.string(),
to_number: z.string(),
direction: z.enum(["inbound", "outbound"]),
call_status: z.string(),
start_timestamp: z.number(),
end_timestamp: z.number().optional(),
disconnection_reason: z.string().optional(),
metadata: z.record(z.any()).optional(),
retell_llm_dynamic_variables: z.record(z.any()).optional(),
transcript: z.string().optional(),
opt_out_sensitive_data_storage: z.boolean().optional(),
call_cost: z
.object({
product_costs: z
.array(
z.object({
product: z.string(),
unitPrice: z.number().optional(),
cost: z.number().optional(),
})
)
.optional(),
total_duration_seconds: z.number().optional(),
total_duration_unit_price: z.number().optional(),
total_one_time_price: z.number().optional(),
combined_cost: z.number().optional(),
})
.optional(),
call_analysis: z
.object({
call_summary: z.string().optional(),
in_voicemail: z.boolean().optional(),
user_sentiment: z.string().optional(),
call_successful: z.boolean().optional(),
custom_analysis_data: z.record(z.any()).optional(),
})
.optional(),
})
.passthrough(),
});
async function handleCallAnalyzed(callData: any) {
const { from_number, call_id, call_cost } = callData;
if (
!call_cost ||
typeof call_cost.total_duration_seconds !== "number" ||
!Number.isFinite(call_cost.total_duration_seconds) ||
call_cost.total_duration_seconds <= 0
) {
log.error(
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
);
return;
}
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({ phoneNumber: from_number });
if (!phoneNumber) {
log.error(`No phone number found for ${from_number}, cannot deduct credits`);
return;
}
// Support both personal and team phone numbers
const userId = phoneNumber.userId;
const teamId = phoneNumber.teamId;
if (!userId && !teamId) {
log.error(`Phone number ${from_number} has no associated user or team`);
return;
}
const rawRatePerMinute = process.env.CAL_AI_CALL_RATE_PER_MINUTE ?? "0.29";
const ratePerMinute = Number.parseFloat(rawRatePerMinute);
const safeRatePerMinute = Number.isFinite(ratePerMinute) && ratePerMinute > 0 ? ratePerMinute : 0.29;
const durationInMinutes = call_cost.total_duration_seconds / 60;
const callCost = durationInMinutes * safeRatePerMinute;
// Convert to cents and round up to ensure we don't undercharge
const creditsToDeduct = Math.ceil(callCost * 100);
const creditService = new CreditService();
try {
await creditService.chargeCredits({
userId: userId ?? undefined,
teamId: teamId ?? undefined,
credits: creditsToDeduct,
callDuration: call_cost.total_duration_seconds,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
externalRef: `retell:${call_id}`,
});
} catch (e) {
log.error("Error charging credits for Retell AI call", {
error: e,
call_id,
call_cost,
userId,
teamId,
});
return {
success: false,
message: `Error charging credits for Retell AI call: ${
e instanceof Error ? e.message : "Unknown error"
}`,
};
}
return {
success: true,
message: `Successfully charged ${creditsToDeduct} credits (${
call_cost.total_duration_seconds
}s at $${safeRatePerMinute}/min) for ${teamId ? `team:${teamId}` : ""} ${
userId ? `user:${userId}` : ""
}, call ${call_id}`,
};
}
/**
* Retell AI Webhook Handler
*
* Setup Instructions:
* 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai
* 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar)
* 3. Set the RETELL_AI_KEY environment variable with your Retell API key (must have webhook badge)
*
* This webhook will:
* - Verify webhook signature for security
* - Receive call_analyzed events from Retell AI
* - Charge credits based on the call cost from the user's or team's credit balance
* - Log all transactions for audit purposes
*/
async function handler(request: NextRequest) {
const rawBody = await request.text();
const body = JSON.parse(rawBody);
// Verify webhook signature
const signature = request.headers.get("x-retell-signature");
const apiKey = process.env.RETELL_AI_KEY;
if (!signature || !apiKey) {
log.error("Missing signature or API key for webhook verification");
return NextResponse.json(
{
error: "Unauthorized",
message: "Missing signature or API key",
},
{ status: 401 }
);
}
if (!Retell.verify(rawBody, apiKey, signature)) {
log.error("Invalid webhook signature");
return NextResponse.json(
{
error: "Unauthorized",
message: "Invalid signature",
},
{ status: 401 }
);
}
if (body.event !== "call_analyzed") {
return NextResponse.json(
{
success: true,
message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`,
},
{ status: 200 }
);
}
try {
const payload = RetellWebhookSchema.parse(body);
const callData = payload.call;
if (callData.direction === "inbound") {
return NextResponse.json(
{
success: true,
message: `Inbound calls are not charged or supported for now. Ignoring call ${callData.call_id}`,
},
{ status: 200 }
);
}
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`);
const result = await handleCallAnalyzed(callData);
return NextResponse.json(
{
success: result?.success ?? true,
message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`,
},
{ status: 200 }
);
} catch (error) {
log.error("Error processing Retell AI webhook:", safeStringify(error));
return NextResponse.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
},
// we need to return 200 to retell ai to avoid retries
{ status: 200 }
);
}
}
export const POST = defaultResponderForAppDir(handler);
+20 -1
View File
@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation";
import type { IframeHTMLAttributes } from "react";
import React, { useEffect, useState } from "react";
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
@@ -112,8 +113,13 @@ export const AppPage = ({
});
const handleAppInstall = () => {
if (isRedirectApp(slug)) {
setIsLoading(true);
mutation.mutate({ type, variant, slug });
return;
}
setIsLoading(true);
if (isConferencing(categories)) {
if (isConferencing(categories) && !concurrentMeetings) {
mutation.mutate({
type,
variant,
@@ -185,6 +191,19 @@ export const AppPage = ({
}, []);
const installOrDisconnectAppButton = () => {
if (isRedirectApp(slug)) {
return (
<Button
onClick={() => handleAppInstall()}
className="mt-2"
StartIcon="external-link"
loading={isLoading}
disabled={isLoading}>
{t("visit")}
</Button>
);
}
if (appDbQuery.isPending) {
return <SkeletonButton className="h-10 w-24" />;
}
@@ -691,7 +691,7 @@ function BookingListItem(booking: BookingItemProps) {
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.href}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={cancelEventAction.onClick}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
@@ -206,6 +206,9 @@ export default function CancelBooking(props: Props) {
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
const response = await fetch("/api/csrf", { cache: "no-store" });
const { csrfToken } = await response.json();
const res = await fetch("/api/cancel", {
body: JSON.stringify({
uid: booking?.uid,
@@ -215,6 +218,7 @@ export default function CancelBooking(props: Props) {
seatReferenceUid,
cancelledBy: currentUserEmail,
internalNote: internalNote,
csrfToken,
}),
headers: {
"Content-Type": "application/json",
@@ -1,4 +1,3 @@
import { type TFunction } from "i18next";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
@@ -20,11 +19,9 @@ interface ConnectedAppStepProps {
}
const ConnectedVideoStepInner = ({
t,
setAnyInstalledVideoApps,
user,
}: {
t: TFunction;
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
user: RouterOutputs["viewer"]["me"]["get"];
}) => {
@@ -94,7 +91,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
return (
<>
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} user={user} />
<Button
EndIcon="arrow-right"
data-testid="save-video-button"
+4 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { Dispatch, SetStateAction, useState } from "react";
import { Button } from "@calcom/ui/components/button";
import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader";
@@ -9,15 +9,7 @@ import { Icon } from "@calcom/ui/components/icon";
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
import { showToast } from "@calcom/ui/components/toast";
interface ContactFormData {
name: string;
email: string;
message: string;
attachments?: FileData[];
}
const PlainContactForm = () => {
const [isOpen, setIsOpen] = useState(false);
const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch<SetStateAction<boolean>> }) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
@@ -62,6 +54,7 @@ const PlainContactForm = () => {
setIsUploadingImage(false);
const formData = new FormData();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
uploadFormData.forEach(({ key, value }: any) => {
formData.append(key, value);
});
@@ -145,7 +138,7 @@ const PlainContactForm = () => {
return (
<div className="fixed bottom-[1rem] right-[1rem] z-50">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Popover open={open} onOpenChange={setIsOpen}>
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
<Button
onClick={() => setIsOpen(true)}
@@ -17,11 +17,15 @@ vi.mock("@calcom/ui/components/toast", () => ({
const mockUseSession = vi.mocked(useSession);
const mockShowToast = vi.mocked(showToast);
const mockFetch = vi.fn();
global.fetch = mockFetch;
global.fetch = mockFetch as unknown as typeof fetch;
describe("PlainContactForm", () => {
let mockSetIsOpen: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockSetIsOpen = vi.fn();
mockUseSession.mockReturnValue({
data: {
hasValidLicense: true,
@@ -35,42 +39,42 @@ describe("PlainContactForm", () => {
},
status: "authenticated",
update: vi.fn(),
});
} as any);
});
it("should render contact button when closed", () => {
render(<PlainContactForm />);
it("renders the contact button when closed", () => {
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
});
it("should open contact form when button is clicked", () => {
render(<PlainContactForm />);
it("opens when the launcher button is clicked (calls setIsOpen(true))", () => {
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(mockSetIsOpen).toHaveBeenCalledWith(true);
});
it("shows the form when open=true", () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
expect(screen.getByText("Contact support")).toBeInTheDocument();
expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
});
it("should show empty form initially", () => {
render(<PlainContactForm />);
const button = screen.getByRole("button");
fireEvent.click(button);
it("shows empty form initially", () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
expect(messageInput.value).toBe("");
});
it("should close form when X button is clicked", () => {
render(<PlainContactForm />);
const openButton = screen.getByRole("button");
fireEvent.click(openButton);
it("closes when X button is clicked (calls setIsOpen(false))", () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
const buttons = screen.getAllByRole("button");
const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
@@ -78,19 +82,16 @@ describe("PlainContactForm", () => {
fireEvent.click(closeButton!);
expect(screen.queryByText("Contact support")).not.toBeInTheDocument();
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
});
it("should handle form submission successfully", async () => {
it("handles form submission successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
render(<PlainContactForm />);
const openButton = screen.getByRole("button");
fireEvent.click(openButton);
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
fireEvent.change(screen.getByLabelText("Describe the issue"), {
target: { value: "Test message" },
@@ -115,14 +116,11 @@ describe("PlainContactForm", () => {
});
});
it("should show loading state during submission", async () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
it("shows loading state during submission", async () => {
// never resolves → stays loading
mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
render(<PlainContactForm />);
const openButton = screen.getByRole("button");
fireEvent.click(openButton);
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
fireEvent.change(screen.getByLabelText("Describe the issue"), {
target: { value: "Test message" },
@@ -135,23 +133,19 @@ describe("PlainContactForm", () => {
await expect(submitButton).toBeDisabled();
});
it("should reset form after successful submission", async () => {
it("resets form after successful submission", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
render(<PlainContactForm />);
const openButton = screen.getByRole("button");
fireEvent.click(openButton);
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
fireEvent.change(screen.getByLabelText("Describe the issue"), {
target: { value: "Test message" },
});
const submitButton = screen.getByRole("button", { name: /send message/i });
fireEvent.click(submitButton);
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
await waitFor(() => {
expect(screen.getByText("Message Sent")).toBeInTheDocument();
@@ -166,27 +160,21 @@ describe("PlainContactForm", () => {
});
});
it("should handle missing user session", () => {
it("handles missing user session", () => {
mockUseSession.mockReturnValue({
data: null,
status: "unauthenticated",
update: vi.fn(),
});
} as any);
render(<PlainContactForm />);
const openButton = screen.getByRole("button");
fireEvent.click(openButton);
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
expect(messageInput.value).toBe("");
});
it("should require message field", async () => {
render(<PlainContactForm />);
const openButton = screen.getByRole("button");
fireEvent.click(openButton);
it("requires message field", async () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
});
+14 -4
View File
@@ -83,6 +83,7 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
? ({ nonce }: { nonce: string | undefined }) => {
const [config, setConfig] = useState<PlainChatConfig | null>(null);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { data: session } = useSession();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -269,10 +270,19 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
initConfig();
if (isPaidUser) {
initConfig();
} else if (typeof window !== "undefined" && !window.Plain) {
window.Plain = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
init: () => {},
open: () => {
setIsOpen(true);
}
};
}
return () => window.removeEventListener("resize", checkScreenSize);
}, [isAppDomain, checkScreenSize, initConfig, userEmail]);
}, [isAppDomain, checkScreenSize, initConfig, userEmail, isPaidUser]);
const plainChatScript = `
window.plainScriptLoaded = function() {
@@ -289,7 +299,7 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
if (!isPaidUser) {
return <PlainContactForm />;
return <PlainContactForm open={isOpen} setIsOpen={setIsOpen} />;
}
if (!config) return null;
@@ -317,7 +317,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isConferencing,
isOrg,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
installableOnTeams: !!appMetadata?.concurrentMeetings || !isConferencing,
} as OnboardingPageProps,
};
};
+2 -1
View File
@@ -14,10 +14,11 @@ export default function useIsBookingPage(): boolean {
"/apps/routing-forms/routing-link", // Routing Form page
"/forms/", // Rewrites to /apps/routing-forms/routing-link
].some((route) => pathname?.startsWith(route));
const isBookingsListPage = ["/upcoming", "/unconfirmed", "/recurring", "/cancelled", "/past"].some((route) => pathname?.endsWith(route));
const searchParams = useCompatSearchParams();
const isUserBookingPage = Boolean(searchParams?.get("user"));
const isUserBookingTypePage = Boolean(searchParams?.get("user") && searchParams?.get("type"));
return isBookingPage || isUserBookingPage || isUserBookingTypePage;
return (isBookingPage && !isBookingsListPage) || isUserBookingPage || isUserBookingTypePage;
}
+185
View File
@@ -0,0 +1,185 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { CreationSource } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
const verifySchema = z.object({
token: z.string(),
});
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
// TODO: To be unit tested
export async function moveUserToMatchingOrg({ email }: { email: string }) {
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
if (!org) {
return;
}
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: org.id,
language: "en",
creationSource: CreationSource.WEBAPP,
invitations: [
{
usernameOrEmail: email,
role: MembershipRole.MEMBER,
},
],
orgSlug: org.slug || org.requestedSlug,
});
}
export async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const billingService = new StripeBillingService();
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
});
if (!foundToken) {
return res.status(401).json({ message: "No token found" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
// The user is verifying the secondary email
if (foundToken?.secondaryEmailId) {
await prisma.secondaryEmail.update({
where: {
id: foundToken.secondaryEmailId,
email: foundToken?.identifier,
},
data: {
emailVerified: new Date(),
},
});
await cleanUpVerificationTokens(foundToken.id);
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
}
const user = await prisma.user.findFirst({
where: {
email: foundToken?.identifier,
},
});
if (!user) {
return res.status(401).json({ message: "Cannot find a user attached to this token" });
}
const userMetadataParsed = userMetadata.parse(user.metadata);
// Attach the new email and verify
if (userMetadataParsed?.emailChangeWaitingForVerification) {
// Ensure this email isn't in use
const existingUser = await prisma.user.findUnique({
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
select: {
id: true,
},
});
if (existingUser) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
// Ensure this email isn't being added by another user as secondary email
const existingSecondaryUser = await prisma.secondaryEmail.findUnique({
where: {
email: userMetadataParsed?.emailChangeWaitingForVerification,
},
select: {
id: true,
userId: true,
},
});
if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
const oldEmail = user.email;
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
delete userMetadataParsed.emailChangeWaitingForVerification;
// Update and re-verify
await prisma.user.update({
where: {
id: user.id,
},
data: {
email: updatedEmail,
metadata: userMetadataParsed,
},
});
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
await billingService.updateCustomer({
customerId: userMetadataParsed.stripeCustomerId,
email: updatedEmail,
});
}
// The user is trying to update the email to an already existing unverified secondary email of his
// so we swap the emails and its verified status
if (existingSecondaryUser?.userId === user.id) {
await prisma.secondaryEmail.update({
where: {
id: existingSecondaryUser.id,
userId: user.id,
},
data: {
email: oldEmail,
emailVerified: user.emailVerified,
},
});
}
await cleanUpVerificationTokens(foundToken.id);
return res.status(200).json({
updatedEmail,
});
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
const hasCompletedOnboarding = user.completedOnboarding;
await moveUserToMatchingOrg({ email: user.email });
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
}
export async function cleanUpVerificationTokens(id: number) {
// Delete token from DB after it has been used
await prisma.verificationToken.delete({
where: {
id,
},
});
}
@@ -12,7 +12,7 @@ import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
import { BookingStatus, RedirectType, SchedulingType } from "@calcom/prisma/client";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
@@ -54,6 +54,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventData = team.eventTypes[0];
if (eventData.schedulingType === SchedulingType.MANAGED) {
return { notFound: true } as const;
}
if (rescheduleUid && eventData.disableRescheduling) {
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
}
@@ -26,6 +26,7 @@ import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
import { localStorage } from "@calcom/lib/webstorage";
import type { MembershipRole } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -176,13 +177,11 @@ const Item = ({
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug ? (
{group.profile.slug && type.schedulingType !== SchedulingType.MANAGED ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={`event-type-slug-${type.id}`}>
{`/${
type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder")
}/${type.slug}`}
{`/${group.profile.slug}/${type.slug}`}
</small>
) : null}
{readOnly && (
@@ -212,7 +211,7 @@ const Item = ({
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug ? (
{group.profile.slug && type.schedulingType !== SchedulingType.MANAGED ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={`event-type-slug-${type.id}`}>
+8 -1
View File
@@ -22,6 +22,7 @@ import {
MostCompletedTeamMembersTable,
LeastCompletedTeamMembersTable,
PopularEventsTable,
RecentNoShowGuestsChart,
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
@@ -94,7 +95,7 @@ function InsightsPageContent() {
<MostCancelledBookingsTables />
<HighestNoShowHostTable />
<div className="sm:col-span-2">
<PopularEventsTable />
<RecentNoShowGuestsChart />
</div>
</div>
@@ -106,6 +107,12 @@ function InsightsPageContent() {
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2">
<PopularEventsTable />
</div>
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
+8 -3
View File
@@ -4,17 +4,22 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export const MembersView = (props: UserListTableProps) => {
export const MembersView = (props: UserListTableProps & { permissions?: MemberPermissions }) => {
const { t } = useLocale();
const { permissions, ...tableProps } = props;
// Use PBAC permissions if available, otherwise fall back to role-based check
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
const canLoggedInUserSeeMembers =
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
permissions?.canListMembers ??
((props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate);
return (
<LicenseRequired>
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
<div>{canLoggedInUserSeeMembers && <UserListTable {...tableProps} permissions={permissions} />}</div>
{!canLoggedInUserSeeMembers && (
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
@@ -1,5 +1,6 @@
"use client";
import { useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -42,9 +43,32 @@ export const CtaRow = ({ title, description, className, children }: CtaRowProps)
const BillingView = () => {
const pathname = usePathname();
const session = useSession();
const { t } = useLocale();
const returnTo = pathname;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
// Determine the billing context and extract appropriate team/org ID
const getTeamIdFromContext = () => {
if (!pathname) return null;
// Team billing: /settings/teams/{id}/billing
if (pathname.includes("/teams/") && pathname.includes("/billing")) {
const teamIdMatch = pathname.match(/\/teams\/(\d+)\/billing/);
return teamIdMatch ? teamIdMatch[1] : null;
}
// Organization billing: /settings/organizations/billing
if (pathname.includes("/organizations/billing")) {
const orgId = session.data?.user?.org?.id;
return typeof orgId === "number" ? orgId.toString() : null;
}
};
const teamId = getTeamIdFromContext();
const billingHref = teamId
? `/api/integrations/stripepayment/portal?teamId=${teamId}&returnTo=${WEBAPP_URL}${returnTo}`
: `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const onContactSupportClick = async () => {
if (window.Plain) {
@@ -1,11 +1,13 @@
"use client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
@@ -27,7 +29,6 @@ type MonthOption = {
endDate: string;
};
// returns the last 12 months starting from May 2025 (when credits were introduced)
const getMonthOptions = (): MonthOption[] => {
const options: MonthOption[] = [];
const minDate = dayjs.utc("2025-05-01");
@@ -53,6 +54,9 @@ const getMonthOptions = (): MonthOption[] => {
export default function BillingCredits() {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const session = useSession();
const orgBranding = useOrgBranding();
const monthOptions = useMemo(() => getMonthOptions(), []);
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
const [isDownloading, setIsDownloading] = useState(false);
@@ -66,9 +70,28 @@ export default function BillingCredits() {
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
const params = useParamsWithFallback();
const teamId = params.id ? Number(params.id) : undefined;
const orgId = session.data?.user?.org?.id;
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
const parsedTeamId = Number(params.id);
const teamId: number | undefined = Number.isFinite(parsedTeamId)
? parsedTeamId
: typeof orgId === "number"
? orgId
: undefined;
const tokens = (pathname ?? "").split("/").filter(Boolean);
const settingsIndex = tokens.indexOf("settings");
const isOrgScopedPath =
settingsIndex >= 0 && ["organizations", "teams"].includes(tokens[settingsIndex + 1]);
const shouldRender = IS_SMS_CREDITS_ENABLED && !(orgId && !isOrgScopedPath && !orgBranding?.slug);
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
{ teamId },
{ enabled: shouldRender }
);
if (!shouldRender) return null;
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
onSuccess: (data) => {
@@ -102,10 +125,6 @@ export default function BillingCredits() {
}
};
if (!IS_SMS_CREDITS_ENABLED) {
return null;
}
if (isLoading && teamId) return <BillingCreditsSkeleton />;
if (!creditsData) return null;
@@ -206,7 +225,7 @@ export default function BillingCredits() {
<hr className="border-subtle mb-3 mt-3" />
</div>
<div className="flex">
<div className="mr-auto ">
<div className="mr-auto">
<Label className="mb-4">{t("download_expense_log")}</Label>
<div className="mt-2 flex flex-col">
<Select
@@ -116,7 +116,7 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
},
weekStart: {
value: user.weekStart,
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
label: weekStartOptions.find((option) => option.value === user.weekStart)?.label || "",
},
travelSchedules:
travelSchedules.map((schedule) => {
+20 -5
View File
@@ -83,6 +83,17 @@ const FEATURES = [
},
];
function truncateDomain(domain: string) {
const maxLength = 25;
const cleanDomain = domain.replace(URL_PROTOCOL_REGEX, "");
if (cleanDomain.length <= maxLength) {
return cleanDomain;
}
return `${cleanDomain.substring(0, maxLength - 3)}.../`;
}
function UsernameField({
username,
setPremium,
@@ -398,11 +409,15 @@ export default function Signup({
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
URL_PROTOCOL_REGEX,
""
)}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
? truncateDomain(
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
URL_PROTOCOL_REGEX,
""
)}/`
)
: truncateDomain(
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
)
}
/>
) : null}
+6 -2
View File
@@ -6,6 +6,7 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberList from "@calcom/features/ee/teams/components/MemberList";
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -23,15 +24,17 @@ interface TeamMembersViewProps {
}[];
};
attributes?: any[];
permissions: MemberPermissions;
}
export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
// Use PBAC permissions if available, otherwise fall back to role-based check
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
return (
<LicenseRequired>
@@ -43,6 +46,7 @@ export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProp
isOrgAdminOrOwner={false}
setShowMemberInvitationModal={setShowMemberInvitationModal}
facetedTeamValues={facetedTeamValues}
permissions={permissions}
/>
</div>
)}
+1 -1
View File
@@ -18,7 +18,7 @@ vi.mock("next/navigation", () => ({
useRouter: vi.fn().mockReturnValue({
replace: vi.fn(),
}),
usePathname: vi.fn(),
usePathname: vi.fn().mockReturnValue("/settings/billing"),
}));
vi.mock("@calcom/app-store/BookingPageTagManager", () => ({
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.6.8",
"version": "5.6.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -56,9 +56,8 @@
"@googleapis/oauth2": "^1.0.7",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.9.7",
"@lingo.dev/_sdk": "^0.7.27",
"@next-auth/prisma-adapter": "^1.0.4",
"@next/bundle-analyzer": "^13.1.6",
"@next/bundle-analyzer": "^15.5.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
+1 -1
View File
@@ -6,7 +6,7 @@ import Document, { Head, Html, Main, NextScript } from "next/document";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { applyTheme, applyToDesktopClass } from "./_applyThemeForDocument";
import { applyTheme, applyToDesktopClass } from "./../lib/pages/document/_applyThemeForDocument";
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
+1 -185
View File
@@ -1,185 +1 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { CreationSource } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
const verifySchema = z.object({
token: z.string(),
});
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
// TODO: To be unit tested
export async function moveUserToMatchingOrg({ email }: { email: string }) {
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
if (!org) {
return;
}
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: org.id,
language: "en",
creationSource: CreationSource.WEBAPP,
invitations: [
{
usernameOrEmail: email,
role: MembershipRole.MEMBER,
},
],
orgSlug: org.slug || org.requestedSlug,
});
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const billingService = new StripeBillingService();
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
});
if (!foundToken) {
return res.status(401).json({ message: "No token found" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
// The user is verifying the secondary email
if (foundToken?.secondaryEmailId) {
await prisma.secondaryEmail.update({
where: {
id: foundToken.secondaryEmailId,
email: foundToken?.identifier,
},
data: {
emailVerified: new Date(),
},
});
await cleanUpVerificationTokens(foundToken.id);
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
}
const user = await prisma.user.findFirst({
where: {
email: foundToken?.identifier,
},
});
if (!user) {
return res.status(401).json({ message: "Cannot find a user attached to this token" });
}
const userMetadataParsed = userMetadata.parse(user.metadata);
// Attach the new email and verify
if (userMetadataParsed?.emailChangeWaitingForVerification) {
// Ensure this email isn't in use
const existingUser = await prisma.user.findUnique({
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
select: {
id: true,
},
});
if (existingUser) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
// Ensure this email isn't being added by another user as secondary email
const existingSecondaryUser = await prisma.secondaryEmail.findUnique({
where: {
email: userMetadataParsed?.emailChangeWaitingForVerification,
},
select: {
id: true,
userId: true,
},
});
if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
const oldEmail = user.email;
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
delete userMetadataParsed.emailChangeWaitingForVerification;
// Update and re-verify
await prisma.user.update({
where: {
id: user.id,
},
data: {
email: updatedEmail,
metadata: userMetadataParsed,
},
});
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
await billingService.updateCustomer({
customerId: userMetadataParsed.stripeCustomerId,
email: updatedEmail,
});
}
// The user is trying to update the email to an already existing unverified secondary email of his
// so we swap the emails and its verified status
if (existingSecondaryUser?.userId === user.id) {
await prisma.secondaryEmail.update({
where: {
id: existingSecondaryUser.id,
userId: user.id,
},
data: {
email: oldEmail,
emailVerified: user.emailVerified,
},
});
}
await cleanUpVerificationTokens(foundToken.id);
return res.status(200).json({
updatedEmail,
});
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
const hasCompletedOnboarding = user.completedOnboarding;
await moveUserToMatchingOrg({ email: user.email });
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
}
export async function cleanUpVerificationTokens(id: number) {
// Delete token from DB after it has been used
await prisma.verificationToken.delete({
where: {
id,
},
});
}
export { handler as default } from "@lib/pages/auth/verify-email";
+18 -1
View File
@@ -4,10 +4,10 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
@@ -37,7 +37,24 @@ async function handler(req: NextApiRequest & { userId?: number }) {
hostname: req.headers.host || "",
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
});
// const booking = await createBookingThroughFactory();
return booking;
// To be added in the follow-up PR
// async function createBookingThroughFactory() {
// console.log("Creating booking through factory");
// const regularBookingService = getRegularBookingService();
// const booking = await regularBookingService.createBooking({
// bookingData: req.body,
// bookingMeta: {
// userId: session?.user?.id || -1,
// hostname: req.headers.host || "",
// forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
// },
// });
// return booking;
// }
}
export default defaultResponder(handler, "/api/book/event");
+1 -1
View File
@@ -4,9 +4,9 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleInstantMeeting from "@calcom/features/instant-meeting/handleInstantMeeting";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
+1 -1
View File
@@ -5,9 +5,9 @@ import { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleN
import type { BookingResponse } from "@calcom/features/bookings/types";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { aiVoiceAgentRouter } from "@calcom/trpc/server/routers/viewer/aiVoiceAgent/_router";
export default createNextApiHandler(aiVoiceAgentRouter);
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { phoneNumberRouter } from "@calcom/trpc/server/routers/viewer/phoneNumber/_router";
export default createNextApiHandler(phoneNumberRouter);
+4
View File
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import { CreditUsageType } from "@calcom/prisma/enums";
import { IS_SMS_CREDITS_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getPublishedOrgIdFromMemberOrTeamId } from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
@@ -101,6 +102,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
creditFor: CreditUsageType.SMS,
});
return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`);
@@ -135,6 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
creditFor: CreditUsageType.SMS,
});
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
@@ -151,6 +154,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
smsSid,
bookingUid: parsedBookingUid,
smsSegments: numSegments ?? undefined,
creditFor: CreditUsageType.SMS,
});
if (chargedUserOrTeamId) {
+3
View File
@@ -3,6 +3,9 @@ import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { installAppleCalendar } from "./lib/testUtils";
// Skipped due to flakiness: navigation to the App Store often exceeds the test timeout locally because of slow performance. Will be re-enabled once performance improvements are made.
test.skip();
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
+5 -1
View File
@@ -548,10 +548,11 @@ test.describe("Booking round robin event", () => {
users,
}) => {
const [testUser] = users.get();
await testUser.apiLogin();
const team = await testUser.getFirstTeamMembership();
await testUser.apiLogin(`/team/${team.team.slug}`);
// Click first event type (round robin)
await page.click('[data-testid="event-type-link"]');
@@ -655,9 +656,12 @@ test.describe("Event type with disabled cancellation and rescheduling", () => {
});
test("Should prevent cancellation and show an error message", async ({ page }) => {
const csrfTokenResponse = await page.request.get("/api/csrf");
const { csrfToken } = await csrfTokenResponse.json();
const response = await page.request.post("/api/cancel", {
data: {
uid: bookingId,
csrfToken,
},
headers: {
"Content-Type": "application/json",
+29 -5
View File
@@ -657,8 +657,8 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
eventTypes: user.eventTypes,
routingForms: user.routingForms,
self,
apiLogin: async (password?: string) =>
apiLogin({ ...(await self()), password: password || user.username }, store.page),
apiLogin: async (navigateToUrl?: string, password?: string) =>
apiLogin({ ...(await self()), password: password || user.username }, store.page, navigateToUrl),
/** Don't forget to close context at the end */
apiLoginOnNewBrowser: async (browser: Browser, password?: string) => {
const newContext = await browser.newContext();
@@ -969,14 +969,18 @@ export async function login(
export async function apiLogin(
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password: string | null },
page: Page
page: Page,
navigateToUrl?: string
) {
// Get CSRF token
const csrfToken = await page
.context()
.request.get("/api/auth/csrf")
.then((response) => response.json())
.then((json) => json.csrfToken);
const data = {
// Make the login request
const loginData = {
email: user.email ?? `${user.username}@example.com`,
password: user.password ?? user.username,
callbackURL: WEBAPP_URL,
@@ -984,10 +988,30 @@ export async function apiLogin(
json: "true",
csrfToken,
};
const response = await page.context().request.post("/api/auth/callback/credentials", {
data,
data: loginData,
});
expect(response.status()).toBe(200);
/**
* Critical: Navigate to a protected page to trigger NextAuth session loading
* This forces NextAuth to run the jwt and session callbacks that populate
* the session with profile, org, and other important data
* We picked /settings/my-account/profile due to it being one of
* our lighest protected pages and doesnt do anything other than load the user profile
*/
await page.goto(navigateToUrl || "/settings/my-account/profile");
// Wait for the session API call to complete to ensure session is fully established
// Only wait if we're on a protected page that would trigger the session API call
try {
await page.waitForResponse("/api/auth/session", { timeout: 2000 });
} catch (error) {
// Session API call not made (likely on a public page), continue anyway
}
return response;
}
+3 -1
View File
@@ -51,7 +51,9 @@ export function createWorkflowPageFixture(page: Page) {
};
const assertListCount = async (count: number) => {
const workflowListCount = await page.locator('[data-testid="workflow-list"] > li');
const workflowListCount = page.locator('[data-testid="workflow-list"] > li');
await expect(workflowListCount.first()).toBeVisible();
await page.reload();
await expect(workflowListCount).toHaveCount(count);
};
+98 -1
View File
@@ -804,6 +804,40 @@
"multiple_duration_timeUnit_short": "{{count}}$t({{unit}}_short)",
"minutes": "Minutes",
"use_cal_ai_to_make_call_description": "Use Cal.ai to get an AI powered phone number or make calls to guests.",
"termination_uri": "Termination URI",
"sip_trunk_username": "SIP Trunk User Name",
"sip_trunk_password": "SIP Trunk Password",
"nickname": "Nickname",
"delete_phone_number": "Delete Phone Number",
"workflow_validation_failed": "Workflow validation failed",
"workflow_validation_empty_fields": "One or more workflow steps have empty message content",
"workflow_validation_unverified_contacts": "One or more phone numbers or email addresses are not verified",
"phone_number_imported_successfully": "Phone number imported and linked to agent successfully",
"phone_number_deleted_successfully": "Phone number deleted successfully",
"delete_phone_number_confirmation": "Are you sure you want to delete this phone number? This action cannot be undone.",
"yes_delete_phone_number": "Yes, delete phone number",
"phone_number_info_tooltip": "The number you are trying to import must be in E.164 format (+country code followed by the number, with no spaces or special characters), e.g. +1234567890",
"termination_uri_info_tooltip": "The termination URI to uniquely identify your elastic SIP trunk. This is used for outbound calls. For Twilio elastic SIP trunks it always ends with .pstn.twilio.com.",
"sip_trunk_username_info_tooltip": "The username used to authenticate the SIP trunk.",
"sip_trunk_password_info_tooltip": "The password used to authenticate the SIP trunk.",
"nickname_info_tooltip": "Nickname of the number. This is for your reference only.",
"save_and_test_call": "Save and Test Call",
"call_initiated_successfully": "Call initiated successfully",
"please_enter_phone_number": "Please enter a phone number",
"agent_updated_successfully": "Agent updated successfully",
"agent_created_successfully": "Agent created successfully",
"phone_number_unsubscribed_successfully": "Phone number unsubscribed successfully",
"general_prompt_description": "This prompt defines the agent's role and primary objectives",
"prompt": "Prompt",
"call_to": "Call to",
"make_test_call_to_verify_configuration": "Make Test Call to Verify Configuration",
"initial_message": "Initial Message",
"initial_message_description": "The first message the agent will say when starting a call",
"having_trouble_importing": "Having trouble importing?",
"learn": "Learn",
"learn_how_to_get_your_terminator": "Learn how to get your Termination URI and SIP trunk credentials",
"phone_number_subscription_cancelled_successfully": "Phone number subscription canceled successfully",
"updating": "Updating",
"round_robin": "Round Robin",
"round_robin_description": "Cycle meetings between multiple team members.",
"managed_event": "Managed Event",
@@ -812,6 +846,48 @@
"managed": "Managed",
"managed_event_url_clarification": "\"username\" will be filled by the username of the members assigned",
"assign_to": "Assign to",
"enter_phone_number_to_test_call": "Enter phone number to test call",
"make_test_call": "Make Test Call",
"create_new_workflow_agent": "Create new workflow cal.ai voice agent",
"create_new_workflow_agent_description": "Create a new workflow cal.ai voice agent to automate your workflows",
"phone_number_for_ai_call": "Phone number (for our agent to call)",
"buy_a_phone_number_or_import_one_you_already_have": "Buy a phone number or import one you already have on twilio",
"test_agent": "Test Agent",
"phone_numbers": "Phone Numbers",
"unsubscribe": "Unsubscribe",
"delete_workflow_step": "Delete Workflow Step",
"unsubscribe_phone_number": "Unsubscribe Phone Number",
"buy_a_new_number": "Buy a new number",
"only_us_phone_numbers_can_be_purchased_here": "You can only buy US phone numbers. For other countries, you can import your own Twilio phone number.",
"buy_number_for_x_per_month": "Buy a US Number for ${{priceInDollars}} per month",
"supported_call_destinations": "Supported Call Destinations",
"no_phone_number_connected": "No phone number connected",
"failed_to_get_workflow_step_id": "Failed to get workflow step ID",
"cal_ai_agent": "Cal.ai Agent",
"test_cal_ai_agent": "Test Cal.ai Agent",
"are_you_sure_you_want_to_delete_workflow_step": "Are you sure you want to delete this workflow step?",
"do_you_still_want_to_unsubscribe": "Do you still want to unsubscribe the phone number from this agent?",
"the_action_will_disconnect_phone_number": "This action will disconnect the phone number from the agent. The agent will not be able to make calls until a new phone number is connected.",
"cal_ai_phone_numbers": "Cal AI Phone Numbers",
"connect_phone_number": "Connect Phone Number",
"cal_ai_phone_numbers_description": "Manage your Cal AI Phone Numbers",
"import_number": "Import Number",
"this_action_will_also": "This action will also:",
"import_phone_number": "Import Phone Number",
"active_subscription": "Active Subscription",
"cancel_your_phone_number_subscription": "Cancel your phone number subscription",
"delete_associated_phone_number": "Delete the associated phone number",
"unauthorized_create_workflow": "You are not authorized to create this workflow",
"import_phone_number_description": "Import your Twilio phone number to use with Cal AI Phone",
"phone_number_cost": "${{price}}/month",
"buy_new_number": "Buy New Number",
"buy_number_cost_x_per_month": "Buying a phone number costs ${{priceInDollars}} per month. You will be charged monthly for each active phone number.",
"area_code_optional": "Area Code (Optional)",
"no_phone_numbers": "No phone numbers",
"phone_number_purchased_successfully": "Phone number purchased successfully",
"cancel_phone_number_subscription": "Cancel Phone Number Subscription",
"yes_cancel_subscription": "Yes, Cancel Subscription",
"cancel_phone_number_subscription_confirmation": "Are you sure you want to cancel this phone number subscription? This action cannot be undone and you will lose access to this phone number.",
"add_members": "Add members...",
"no_assigned_members": "No assigned members",
"assigned_to": "Assigned to",
@@ -1462,6 +1538,7 @@
"open": "Open",
"close": "Close",
"upgrade": "Upgrade",
"buy_us_number_button": "Buy US number for {{priceInDollars}}$/month",
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
"upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
"upgrade_to_cal_ai_phone_number_description": "Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls",
@@ -2635,6 +2712,9 @@
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
"account_already_linked": "Account is already linked",
"send_email": "Send email",
"cal_ai_phone_call_action": "Call attendee using Cal.ai Voice Agent",
"cal_ai_agent_configuration": "Cal.ai Agent Configuration",
"choose_at_least_one_event_type_test_call": "Please choose at least one event type to make a test call.",
"mark_as_no_show": "Mark as no-show",
"unmark_as_no_show": "Unmark no-show",
"account_unlinked_success": "Account unlinked successfully",
@@ -2643,6 +2723,7 @@
"travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.",
"schedule_timezone_change": "Schedule timezone change",
"date": "Date",
"enter_the_general_prompt_for_the_agent": "Enter the general prompt for the agent",
"overlaps_with_existing_schedule": "This overlaps with an existing schedule. Please select a different date.",
"org_admin_no_slots|heading": "No availability found for {{name}}",
"org_admin_no_slots|content": "Hello Organization Admins,<br /><br />Please note: It has been brought to our attention that {{username}} has not had any availability when a user has visited {{username}}/{{slug}}<br /><br />There's a few reasons why this could be happening<br />The user does not have any calendars connected<br />Their schedules attached to this event are not enabled<br /> <br />We recommend checking their availability to resolve this.",
@@ -3273,6 +3354,8 @@
"routing_form_select_members_to_email": "Send email responses to",
"routing_incomplete_booking_tab": "Incomplete Bookings",
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
"recent_no_show_guests": "Recent No-Show Guests",
"recent_no_show_guests_tooltip": "Shows bookings where all attendees were no-shows, not partial no-shows",
"matching": "Matching",
"event_redirect": "Event Redirect",
"reset_form": "Reset Form",
@@ -3320,6 +3403,7 @@
"pbac_action_read_team_bookings": "View Team Bookings",
"pbac_action_read_org_bookings": "View Organization Bookings",
"pbac_action_read_recordings": "View Recordings",
"pbac_action_impersonate": "Impersonate",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
@@ -3337,6 +3421,7 @@
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
"pbac_desc_create_workflows": "Create and set up new workflows",
"set_up_required": "Set up required",
"pbac_desc_view_workflows": "View existing workflows and their configurations",
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
@@ -3353,7 +3438,8 @@
"pbac_desc_invite_team_members": "Invite team members",
"pbac_desc_remove_team_members": "Remove team members",
"pbac_desc_change_team_member_role": "Change role of team members",
"pbac_desc_manage_teams": "All actions on teams",
"pbac_desc_impersonate_team_members": "Impersonate team members",
"pbac_desc_manage_teams": "All actions on teams across organization teams",
"pbac_desc_create_organization": "Create organization",
"pbac_desc_view_organization_details": "View organization details",
"pbac_desc_list_organization_members": "List organization members",
@@ -3361,6 +3447,7 @@
"pbac_desc_remove_organization_members": "Remove organization members",
"pbac_desc_manage_organization_billing": "Manage organization billing",
"pbac_desc_change_organization_member_role": "Change role of organization members",
"pbac_desc_impersonate_organization_members": "Impersonate organization members",
"pbac_desc_edit_organization_settings": "Edit organization settings",
"pbac_desc_manage_organizations": "All actions on organizations",
"pbac_desc_view_bookings": "View bookings",
@@ -3427,6 +3514,8 @@
"file_upload_instructions": "Accepted types: {{acceptedTypes}}; Max file size: {{maxSize}}",
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"show_optimized_slots": "Optimized slots",
"show_optimized_slots_description": "Arrange time slots to optimize availability.",
"failed_bookings_by_field": "Failed Bookings By Field",
"failed_bookings": "Failed bookings",
"event_type_no_hosts": "No hosts are assigned to event type",
@@ -3487,6 +3576,7 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
"my_bookings": "My Bookings",
@@ -3494,5 +3584,12 @@
"free": "Free",
"user_email": "User Email",
"user_name": "User Name",
"expand_panel": "Expand Panel",
"collapse_panel": "Collapse Panel",
"you_have_one_team": "You have one team",
"consider_consolidating_one_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your team.",
"consider_consolidating_multi_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your teams.",
"search_variables": "Search variables",
"no_variables_found": "No variables found",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -121,6 +121,7 @@ describe("handleChildrenEventTypes", () => {
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -182,6 +183,7 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -292,6 +294,7 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -356,6 +359,7 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment,
rrSegmentQueryValue,
useEventLevelSelectedCalendars,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -421,6 +425,7 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -444,6 +449,7 @@ describe("handleChildrenEventTypes", () => {
requiresBookerEmailVerification: false,
lockTimeZoneToggleOnBookingPage: false,
useEventTypeDestinationCalendarEmail: false,
showOptimizedSlots: false,
workflows: [],
parentId: 1,
locations: [],
@@ -1,7 +1,6 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails";
@@ -13,8 +12,8 @@ export function getMockPaymentService() {
function createPaymentLink(/*{ paymentUid, name, email, date }*/) {
return "http://mock-payment.example.com/";
}
const paymentUid = uuidv4();
const externalId = uuidv4();
const paymentUid = "MOCK_PAYMENT_UID";
const externalId = "mock_payment_external_id";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -37,7 +36,7 @@ export function getMockPaymentService() {
bookingId,
// booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
fee: 10,
success: true,
success: false,
refunded: false,
data: {},
externalId,
@@ -46,11 +45,16 @@ export function getMockPaymentService() {
currency: payment.currency,
};
const paymentData = prismaMock.payment.create({
const paymentData = await prismaMock.payment.create({
data: paymentCreateData,
});
logger.silly("Created mock payment", JSON.stringify({ paymentData }));
const verifyPayment = await prismaMock.payment.findFirst({
where: { externalId: paymentCreateData.externalId },
});
logger.silly("Verified payment exists", JSON.stringify({ verifyPayment }));
return paymentData;
}
async afterPayment(
@@ -1,4 +1,3 @@
import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
@@ -51,6 +50,22 @@ vi.mock("@calcom/app-store/calendar.services.generated", () => ({
},
}));
const mockVideoAdapterRegistry: Record<string, any> = {};
vi.mock("@calcom/app-store/video.adapters.generated", () => ({
VideoApiAdapterMap: new Proxy(
{},
{
get(target, prop) {
if (typeof prop === "string" && mockVideoAdapterRegistry[prop]) {
return mockVideoAdapterRegistry[prop];
}
return Promise.resolve({ default: vi.fn() });
},
}
),
}));
// We don't need to test it. Also, it causes Formbricks error when imported
vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({
default: {},
@@ -1995,67 +2010,61 @@ export function mockVideoApp({
const updateMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
VideoApiAdapter: (credential) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeeting: (...rest: any[]) => {
if (creationCrash) {
throw new Error("MockVideoApiAdapter.createMeeting fake error");
}
createMeetingCalls.push({
credential,
args: rest,
});
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => {
if (updationCrash) {
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
}
const [bookingRef, calEvent] = rest;
updateMeetingCalls.push({
credential,
args: rest,
});
if (!bookingRef.type) {
throw new Error("bookingRef.type is not defined");
}
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent }));
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deleteMeeting: async (...rest: any[]) => {
log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest));
deleteMeetingCalls.push({
credential,
args: rest,
});
},
};
},
},
});
});
const mockVideoAdapter = (credential: any) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeeting: (...rest: any[]) => {
if (creationCrash) {
throw new Error("MockVideoApiAdapter.createMeeting fake error");
}
createMeetingCalls.push({
credential,
args: rest,
});
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => {
if (updationCrash) {
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
}
const [bookingRef, calEvent] = rest;
updateMeetingCalls.push({
credential,
args: rest,
});
if (!bookingRef.type) {
throw new Error("bookingRef.type is not defined");
}
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent }));
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deleteMeeting: async (...rest: any[]) => {
log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest));
deleteMeetingCalls.push({
credential,
args: rest,
});
},
};
};
mockVideoAdapterRegistry[appStoreLookupKey] = Promise.resolve({
default: mockVideoAdapter,
});
return {
createMeetingCalls,
updateMeetingCalls,
@@ -2105,18 +2114,7 @@ export function mockPaymentApp({
appStoreLookupKey?: string;
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
const { paymentUid, externalId, MockPaymentService } = getMockPaymentService();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
PaymentService: MockPaymentService,
},
});
});
});
const { paymentUid, externalId } = getMockPaymentService();
return {
paymentUid,
@@ -2132,22 +2130,15 @@ export function mockErrorOnVideoMeetingCreation({
appStoreLookupKey?: string;
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
VideoApiAdapter: () => ({
createMeeting: () => {
throw new MockError("Error creating Video meeting");
},
}),
},
});
});
const mockErrorAdapter = () => ({
createMeeting: () => {
throw new MockError("Error creating Video meeting");
},
});
mockVideoAdapterRegistry[appStoreLookupKey] = Promise.resolve({
default: mockErrorAdapter,
});
}
@@ -2175,42 +2166,49 @@ export function mockCrmApp(
ownerEmail: string;
}[] = [];
const eventsCreated: boolean[] = [];
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
const appMock = appStoreMock.default[metadataLookupKey as keyof typeof appStoreMock.default];
appMock &&
`mockResolvedValue` in appMock &&
appMock.mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CrmService: class {
constructor() {
log.debug("Create CrmSerive");
}
createContact() {
if (crmData?.createContacts) {
contactsCreated = crmData.createContacts;
return Promise.resolve(crmData?.createContacts);
}
}
// Mock the CrmServiceMap directly instead of using the old app-store index approach
vi.doMock("@calcom/app-store/crm.apps.generated", async (importOriginal) => {
const original = await importOriginal<typeof import("@calcom/app-store/crm.apps.generated")>();
getContacts(email: string) {
if (crmData?.getContacts) {
contactsQueried = crmData?.getContacts;
const contactsOfEmail = contactsQueried.filter((contact) => contact.email === email);
class MockCrmService {
constructor() {
log.debug("Create CrmService");
}
return Promise.resolve(contactsOfEmail);
}
}
createContact() {
if (crmData?.createContacts) {
contactsCreated = crmData.createContacts;
return Promise.resolve(crmData?.createContacts);
}
return Promise.resolve([]);
}
createEvent() {
eventsCreated.push(true);
return Promise.resolve({});
}
},
getContacts(email: string) {
if (crmData?.getContacts) {
contactsQueried = crmData?.getContacts;
const contactsOfEmail = contactsQueried.filter((contact) => contact.email === email);
return Promise.resolve(contactsOfEmail);
}
return Promise.resolve([]);
}
createEvent() {
eventsCreated.push(true);
return Promise.resolve({});
}
}
return {
...original,
CrmServiceMap: {
...original.CrmServiceMap,
[metadataLookupKey]: Promise.resolve({
default: MockCrmService,
}),
},
});
};
});
return {
contactsCreated,
+1 -1
View File
@@ -38,5 +38,5 @@ Below is a list of props that can be passed to the Cal Provider.
| options | Yes | Configuration options - `apiUrl` (should be https://api.cal.com/v2) and `refreshUrl` (URL of endpoint you have to build that to which atoms will send expired access tokens and receive new one in return. Read how to set it up [here](https://cal.com/docs/platform/quickstart#4-backend%3A-setting-up-a-refresh-token-endpoint)) and `readingDirection` (defaults to "ltr" but can also pass "rtl" which will change direction of UI components) |
| accessToken | No | The access token of your managed user for whom cal handles scheduling. |
| autoUpdateTimezone | No | Whether to automatically update managed user timezone (default: true) |
| language | No | Language code (default: "en") |
| language | No | Language code (default: "en") - available languages: "en", "de", "fr", "it", "nl", "pt-BR", "es" |
| organizationId | No | ID of your organization |
+1 -1
View File
@@ -9,7 +9,6 @@
"packages/embeds/*",
"packages/features/*",
"packages/app-store/*",
"packages/app-store/ee/*",
"packages/platform/*",
"packages/platform/examples/base",
"example-apps/*"
@@ -96,6 +95,7 @@
"c8": "^7.13.0",
"checkly": "latest",
"dotenv-checker": "^1.1.5",
"eslint-plugin-import": "^2.32.0",
"husky": "^8.0.0",
"i18n-unused": "^0.13.0",
"jest-diff": "^29.5.0",
+54
View File
@@ -415,6 +415,58 @@ function generateFiles() {
analyticsOutput.push(...analyticsServices);
}
const paymentOutput = [];
const paymentServices = getExportedObject(
"PaymentServiceMap",
{
importConfig: {
fileToBeImported: "lib/PaymentService.ts",
importName: "PaymentService",
},
lazyImport: true,
},
(app: App) => {
const hasPaymentService = fs.existsSync(path.join(APP_STORE_PATH, app.path, "lib/PaymentService.ts"));
return hasPaymentService;
}
);
paymentOutput.push(...paymentServices);
const videoOutput = [];
const videoAdapters = getExportedObject(
"VideoApiAdapterMap",
{
importConfig: {
fileToBeImported: "lib/VideoApiAdapter.ts",
importName: "default",
},
lazyImport: true,
},
(app: App) => {
return fs.existsSync(path.join(APP_STORE_PATH, app.path, "lib/VideoApiAdapter.ts"));
}
);
const videoExportLineIndex = videoAdapters.findIndex((line) =>
line.startsWith("export const VideoApiAdapterMap")
);
if (videoExportLineIndex !== -1) {
const exportLine = videoAdapters[videoExportLineIndex];
const objectContent = videoAdapters.slice(videoExportLineIndex + 1, -1);
videoOutput.push(
exportLine.replace(
"export const VideoApiAdapterMap = {",
"export const VideoApiAdapterMap = process.env.NEXT_PUBLIC_IS_E2E ? {} : {"
),
...objectContent,
"};"
);
} else {
videoOutput.push(...videoAdapters);
}
const banner = `/**
This file is autogenerated using the command \`yarn app-store:build --watch\`.
Don't modify this file manually.
@@ -430,6 +482,8 @@ function generateFiles() {
["bookerApps.metadata.generated.ts", bookerMetadataOutput],
["crm.apps.generated.ts", crmOutput],
["calendar.services.generated.ts", calendarOutput],
["payment.services.generated.ts", paymentOutput],
["video.adapters.generated.ts", videoOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
+24
View File
@@ -0,0 +1,24 @@
export const REDIRECT_APPS = [
"amie",
"autocheckin",
"bolna",
"chatbase",
"clic",
"deel",
"elevenlabs",
"granola",
"greetmate-ai",
"lindy",
"linear",
"millis-ai",
"monobot",
"retell-ai",
"synthflow",
"telli",
"vimcal",
"wordpress",
];
export const isRedirectApp = (slug: string): boolean => {
return REDIRECT_APPS.includes(slug);
};
@@ -0,0 +1,451 @@
// This script is used to delete recordings older than 6 months from Daily.co
//
// Commands to run:
// 1. Fetch recordings older than 6 months and save to JSON:
// ts-node packages/app-store/dailyvideo/lib/scripts/deleteRecordings.ts
//
// 2. Delete recordings from the JSON file (shows preview first, then prompts for confirmation):
// ts-node packages/app-store/dailyvideo/lib/scripts/deleteRecordings.ts delete
interface Recording {
id: string;
room_name: string;
mtgSessionId: string;
status: string;
start_ts: number;
duration: number;
max_participants?: number;
share_token?: string;
s3key?: string;
isVttEnabled?: boolean;
tracks?: unknown[];
}
interface RecordingsResponse {
data: Recording[];
total_count: number;
}
async function getAllRecordingsOlderThan6Months(): Promise<Recording[]> {
const apiKey = process.env.DAILY_API_KEY;
if (!apiKey) {
console.error("DAILY_API_KEY environment variable is required");
process.exit(1);
}
const baseUrl = "https://api.daily.co/v1/recordings";
const allRecordings: Recording[] = [];
const limit = 100;
// Calculate date 6 months ago
const cutoffDate = new Date();
cutoffDate.setUTCMonth(cutoffDate.getUTCMonth() - 6);
cutoffDate.setUTCHours(0, 0, 0, 0);
const cutoffTimestamp = Math.floor(cutoffDate.getTime() / 1000);
console.log(
`Fetching all recordings older than 6 months (before ${
cutoffDate.toISOString().split("T")[0]
}, timestamp: ${cutoffTimestamp})...`
);
let hasMoreRecordings = true;
let requestCount = 0;
const startTime = Date.now();
let endingBefore = "OLDEST";
while (hasMoreRecordings) {
requestCount++;
const elapsedTime = Date.now() - startTime;
const expectedMinTime = (requestCount - 1) * 50;
if (elapsedTime < expectedMinTime) {
const delayTime = expectedMinTime - elapsedTime;
console.log(`Rate limiting: waiting ${delayTime}ms before next request...`);
await new Promise((resolve) => setTimeout(resolve, delayTime));
}
const url = new URL(baseUrl);
url.searchParams.append("limit", limit.toString());
if (endingBefore) {
url.searchParams.append("ending_before", endingBefore);
}
let retries = 0;
const maxRetries = 5;
let response: Response | undefined;
while (retries <= maxRetries) {
try {
console.log("url", url.toString());
response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
break;
}
if (response.status === 404) {
console.log("No recordings found or endpoint not available");
return [];
}
if (response.status === 429) {
if (retries < maxRetries) {
const backoffDelay = Math.pow(2, retries) * 1000;
console.log(
`Rate limit exceeded (429). Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${
maxRetries + 1
})`
);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
retries++;
continue;
} else {
throw new Error(`Rate limit exceeded after ${maxRetries + 1} attempts`);
}
}
throw new Error(`HTTP error! status: ${response.status}`);
} catch (error) {
if (retries < maxRetries && (error as Error).message.includes("fetch")) {
const backoffDelay = Math.pow(2, retries) * 1000;
console.log(
`Network error. Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${maxRetries + 1})`
);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
retries++;
continue;
}
throw error;
}
}
if (!response?.ok) {
throw new Error(`Failed to fetch recordings after ${maxRetries + 1} attempts`);
}
const data = (await response.json()) as RecordingsResponse;
if (!data.data || data.data.length === 0) {
console.log("No more recordings available, ending pagination");
hasMoreRecordings = false;
break;
}
const filteredRecordings = data.data.filter((recording) => {
return recording.start_ts < cutoffTimestamp;
});
allRecordings.push(...filteredRecordings);
console.log(
`Fetched ${data.data.length} recordings, ${filteredRecordings.length} older than 6 months (total: ${allRecordings.length})`
);
endingBefore = data.data[0].id;
console.log("endingBefore", endingBefore);
console.log("first recording in batch", data.data[0]);
console.log("last recording in batch", data.data[data.data.length - 1]);
if (data.data.length < limit) {
console.log("Received fewer results than limit, reached end of data");
hasMoreRecordings = false;
break;
}
if (filteredRecordings.length === 0 && data.data.every((r) => r.start_ts >= cutoffTimestamp)) {
console.log("Reached recordings newer than 6 months, stopping");
hasMoreRecordings = false;
break;
}
}
return allRecordings;
}
async function saveRecordingsToJson(recordings: Recording[]): Promise<void> {
const fs = await import("fs/promises");
const path = await import("path");
const simplifiedRecordings = recordings.map((recording) => ({
id: recording.id,
room_name: recording.room_name,
start_ts: recording.start_ts,
start_date: new Date(recording.start_ts * 1000).toISOString(),
duration: recording.duration,
status: recording.status,
mtgSessionId: recording.mtgSessionId,
max_participants: recording.max_participants,
share_token: recording.share_token,
s3key: recording.s3key,
isVttEnabled: recording.isVttEnabled,
}));
const outputPath = path.join(process.cwd(), "recordings_older_than_6_months.json");
try {
await fs.writeFile(outputPath, JSON.stringify(simplifiedRecordings, null, 2), "utf-8");
console.log(`\nRecordings saved to: ${outputPath}`);
} catch (error) {
console.error("Error saving recordings to JSON:", error);
}
}
async function main(): Promise<void> {
console.log("Fetching all recordings older than 6 months...");
const recordings = await getAllRecordingsOlderThan6Months();
const totalDurationSeconds = recordings.reduce(
(total: number, recording: Recording) => total + recording.duration,
0
);
const totalHours = Math.floor(totalDurationSeconds / 3600);
const remainingMinutes = Math.floor((totalDurationSeconds % 3600) / 60);
const remainingSeconds = totalDurationSeconds % 60;
console.log(`\nFinal result: ${recordings.length} recordings found older than 6 months`);
console.log(
`Total duration: ${totalDurationSeconds} seconds (${totalHours}h ${remainingMinutes}m ${remainingSeconds}s)`
);
await saveRecordingsToJson(recordings);
console.log("\nLast 10 recordings (oldest first):");
const lastTen = recordings.slice(-10);
lastTen.forEach((recording: Recording, index: number) => {
const startTime = new Date(recording.start_ts * 1000).toISOString();
const durationMinutes = Math.floor(recording.duration / 60);
const durationSeconds = recording.duration % 60;
console.log(
`${index + 1}. ${recording.room_name} (${
recording.id
}) - Started: ${startTime} - Duration: ${durationMinutes}m ${durationSeconds}s - Status: ${
recording.status
}`
);
});
if (recordings.length > 10) {
console.log(`\n... and ${recordings.length - 10} more recordings before these`);
}
}
async function deleteRecording(recordingId: string, apiKey: string): Promise<boolean> {
const url = `https://api.daily.co/v1/recordings/${recordingId}`;
const maxRetries = 5;
let retries = 0;
while (retries <= maxRetries) {
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
const result = await response.json();
return result.deleted === true;
}
if (response.status === 404) {
console.log(`Recording ${recordingId} not found (may have been already deleted)`);
return true;
}
if (response.status === 429) {
if (retries < maxRetries) {
const backoffDelay = Math.pow(2, retries) * 1000;
console.log(
`Rate limit for ${recordingId}. Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${
maxRetries + 1
})`
);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
retries++;
continue;
}
}
console.error(`Failed to delete recording ${recordingId}: HTTP ${response.status}`);
return false;
} catch (error) {
const errorMessage = (error as Error).message;
if (
retries < maxRetries &&
(errorMessage.includes("fetch failed") || errorMessage.includes("TIMEOUT"))
) {
const backoffDelay = Math.pow(2, retries) * 1000;
console.log(
`Network error for ${recordingId}. Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${
maxRetries + 1
})`
);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
retries++;
continue;
}
console.error(`Error deleting recording ${recordingId} after ${retries + 1} attempts:`, error);
return false;
}
}
console.error(`Failed to delete recording ${recordingId} after ${maxRetries + 1} attempts`);
return false;
}
async function deleteRecordingsFromJson(dryRun = false): Promise<void> {
const apiKey = process.env.DAILY_API_KEY;
if (!apiKey) {
console.error("DAILY_API_KEY environment variable is required");
process.exit(1);
}
const fs = await import("fs/promises");
const path = await import("path");
const filePath = path.join(process.cwd(), "recordings_older_than_6_months.json");
try {
const fileContent = await fs.readFile(filePath, "utf-8");
const recordings = JSON.parse(fileContent) as Array<{
id: string;
room_name: string;
start_date: string;
duration: number;
status: string;
}>;
console.log(`\nFound ${recordings.length} recordings to delete`);
if (recordings.length === 0) {
console.log("No recordings to delete");
return;
}
const totalDuration = recordings.reduce((sum, rec) => sum + rec.duration, 0);
const totalHours = Math.floor(totalDuration / 3600);
const totalMinutes = Math.floor((totalDuration % 3600) / 60);
console.log(`Total duration: ${totalHours}h ${totalMinutes}m`);
console.log(`Date range: ${recordings[0].start_date} to ${recordings[recordings.length - 1].start_date}`);
if (dryRun) {
console.log("\n🔸 DRY RUN MODE - No recordings will be deleted");
console.log("First 5 recordings that would be deleted:");
recordings.slice(0, 5).forEach((rec, idx) => {
console.log(` ${idx + 1}. ${rec.id} - ${rec.room_name} (${rec.start_date})`);
});
if (recordings.length > 5) {
console.log(` ... and ${recordings.length - 5} more`);
}
return;
}
let successCount = 0;
let failCount = 0;
const startTime = Date.now();
const batchSize = 10;
for (let i = 0; i < recordings.length; i += batchSize) {
const batch = recordings.slice(i, Math.min(i + batchSize, recordings.length));
const batchPromises = [];
for (const recording of batch) {
const elapsedTime = Date.now() - startTime;
const expectedMinTime = (successCount + failCount) * 50;
if (elapsedTime < expectedMinTime) {
const delayTime = expectedMinTime - elapsedTime;
await new Promise((resolve) => setTimeout(resolve, delayTime));
}
const deletePromise = deleteRecording(recording.id, apiKey).then((success) => {
if (success) {
successCount++;
} else {
failCount++;
}
if ((successCount + failCount) % 10 === 0) {
const progress = (((successCount + failCount) / recordings.length) * 100).toFixed(1);
console.log(
`Progress: ${progress}% (${successCount + failCount}/${recordings.length}), recording-id:${
recording.id
}`
);
}
return success;
});
batchPromises.push(deletePromise);
}
await Promise.all(batchPromises);
}
console.log("\n✅ Deletion complete!");
console.log(`Successfully deleted: ${successCount}`);
console.log(`Failed to delete: ${failCount}`);
console.log(`Total processed: ${successCount + failCount}`);
if (failCount > 0) {
console.log("\n⚠️ Some recordings failed to delete. Check the logs above for details.");
}
} catch (error) {
console.error("Error reading or processing recordings file:", error);
process.exit(1);
}
}
async function mainDelete(): Promise<void> {
const readline = (await import("readline")).createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("🗑️ Daily Video Recording Deletion Tool");
console.log("=====================================");
console.log("This tool will delete all recordings listed in recordings_older_than_6_months.json");
console.log("\n⚠️ WARNING: This action cannot be undone!");
console.log("Note: Recordings in custom S3 buckets will NOT be deleted from S3.\n");
await deleteRecordingsFromJson(true);
const question = (query: string): Promise<string> =>
new Promise((resolve) => readline.question(query, resolve));
const answer = await question("\nDo you want to proceed with deletion? (yes/no): ");
if (answer.toLowerCase() === "yes") {
console.log("\nStarting deletion process...\n");
await deleteRecordingsFromJson(false);
} else {
console.log("\nDeletion cancelled.");
}
readline.close();
}
const args = process.argv.slice(2);
if (args[0] === "delete") {
mainDelete();
} else {
main();
}
+5 -3
View File
@@ -3,7 +3,9 @@ import { z, ZodError } from "zod";
import { GiphyManager } from "../lib";
const giphyUrlRegexp = new RegExp("^https://(.*).giphy.com/media/(.*)/giphy.gif(.*)");
const giphyUrlRegexp = new RegExp(
"^https?:\\/\\/(?:[\\w.-]+\\.)?giphy\\.com\\/media\\/(?:[^/]+\\/)*([a-zA-Z0-9_-]+)\\/giphy\\.gif(?:\\?.*)?$"
);
const getSchema = z.object({
url: z.string().regex(giphyUrlRegexp, "Giphy URL is invalid"),
@@ -26,10 +28,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const sanitisedUrl = parsedUrl.origin + parsedUrl.pathname;
// Extract Giphy ID from embed url
const matches = giphyUrlRegexp.exec(sanitisedUrl);
if (!matches || matches.length < 3) {
if (!matches || matches.length < 2) {
return res.status(400).json({ message: "Giphy URL is invalid" });
}
const giphyId = matches[2];
const giphyId = matches[1];
const gifImageUrl = await GiphyManager.getGiphyById(giphyId);
return res.status(200).json({ image: gifImageUrl });
} catch (error: unknown) {
-84
View File
@@ -1,84 +0,0 @@
const appStore = {
alby: createCachedImport(() => import("./alby")),
applecalendar: createCachedImport(() => import("./applecalendar")),
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
closecom: createCachedImport(() => import("./closecom")),
dailyvideo: createCachedImport(() => import("./dailyvideo")),
dub: createCachedImport(() => import("./dub")),
googlecalendar: createCachedImport(() => import("./googlecalendar")),
googlevideo: createCachedImport(() => import("./googlevideo")),
hubspot: createCachedImport(() => import("./hubspot")),
huddle01video: createCachedImport(() => import("./huddle01video")),
"ics-feedcalendar": createCachedImport(() => import("./ics-feedcalendar")),
jellyconferencing: createCachedImport(() => import("./jelly")),
jitsivideo: createCachedImport(() => import("./jitsivideo")),
larkcalendar: createCachedImport(() => import("./larkcalendar")),
nextcloudtalkvideo: createCachedImport(() => import("./nextcloudtalk")),
office365calendar: createCachedImport(() => import("./office365calendar")),
office365video: createCachedImport(() => import("./office365video")),
plausible: createCachedImport(() => import("./plausible")),
paypal: createCachedImport(() => import("./paypal")),
"pipedrive-crm": createCachedImport(() => import("./pipedrive-crm")),
salesforce: createCachedImport(() => import("./salesforce")),
zohocrm: createCachedImport(() => import("./zohocrm")),
sendgrid: createCachedImport(() => import("./sendgrid")),
stripepayment: createCachedImport(() => import("./stripepayment")),
tandemvideo: createCachedImport(() => import("./tandemvideo")),
vital: createCachedImport(() => import("./vital")),
zoomvideo: createCachedImport(() => import("./zoomvideo")),
wipemycalother: createCachedImport(() => import("./wipemycalother")),
webexvideo: createCachedImport(() => import("./webex")),
giphy: createCachedImport(() => import("./giphy")),
zapier: createCachedImport(() => import("./zapier")),
make: createCachedImport(() => import("./make")),
exchange2013calendar: createCachedImport(() => import("./exchange2013calendar")),
exchange2016calendar: createCachedImport(() => import("./exchange2016calendar")),
exchangecalendar: createCachedImport(() => import("./exchangecalendar")),
facetime: createCachedImport(() => import("./facetime")),
sylapsvideo: createCachedImport(() => import("./sylapsvideo")),
zohocalendar: createCachedImport(() => import("./zohocalendar")),
"zoho-bigin": createCachedImport(() => import("./zoho-bigin")),
basecamp3: createCachedImport(() => import("./basecamp3")),
telegramvideo: createCachedImport(() => import("./telegram")),
shimmervideo: createCachedImport(() => import("./shimmervideo")),
hitpay: createCachedImport(() => import("./hitpay")),
btcpayserver: createCachedImport(() => import("./btcpayserver")),
};
function createCachedImport<T>(importFunc: () => Promise<T>): () => Promise<T> {
let cachedModule: T | undefined;
let isLoading = false;
let loadingPromise: Promise<T> | undefined;
return async () => {
if (cachedModule) return cachedModule;
if (isLoading && loadingPromise) return loadingPromise;
isLoading = true;
loadingPromise = importFunc()
.then((module) => {
cachedModule = module;
isLoading = false;
return module;
})
.catch((err) => {
throw err;
})
.finally(() => {
isLoading = false;
loadingPromise = undefined;
});
return loadingPromise;
};
}
const exportedAppStore: typeof appStore & {
["mock-payment-app"]?: () => Promise<typeof import("./mock-payment-app/index")>;
} = appStore;
if (process.env.MOCK_PAYMENT_APP_ENABLED !== undefined) {
exportedAppStore["mock-payment-app"] = createCachedImport(() => import("./mock-payment-app/index"));
}
export default exportedAppStore;
@@ -0,0 +1,12 @@
/**
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
export const PaymentServiceMap = {
alby: import("./alby/lib/PaymentService"),
btcpayserver: import("./btcpayserver/lib/PaymentService"),
hitpay: import("./hitpay/lib/PaymentService"),
"mock-payment-app": import("./mock-payment-app/lib/PaymentService"),
paypal: import("./paypal/lib/PaymentService"),
stripepayment: import("./stripepayment/lib/PaymentService"),
};
@@ -52,7 +52,7 @@ export const PaypalPaymentComponent = (props: IPaypalPaymentComponentProps) => {
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-[#ffc439] px-12 py-2 text-base
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
Pay with
<img src="/api/app-store/paypal/paypal-logo.svg" alt="Paypal" className="mx-2 mb-1 mt-2 w-16" />
<img src="/app-store/paypal/paypal-logo.svg" alt="Paypal" className="mx-2 mb-1 mt-2 w-16" />
<span />
</Link>
</div>
+64 -11
View File
@@ -2,33 +2,86 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getStripeCustomerIdFromUserId } from "../lib/customer";
import stripe from "../lib/server";
import { getSubscriptionFromId } from "../lib/subscriptions";
const getBillingPortalUrl = async (customerId: string, return_url: string) => {
const log = logger.getSubLogger({ prefix: ["getBillingPortalUrl"] });
try {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
return portalSession.url;
} catch (e) {
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
throw new Error("Failed to create billing portal session");
}
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST" && req.method !== "GET")
return res.status(405).json({ message: "Method not allowed" });
// if (!referer) return res.status(400).json({ message: "Missing referrer" });
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
// If accessing a user's portal
const customerId = await getStripeCustomerIdFromUserId(req.session.user.id);
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
const userId = req.session.user.id;
const teamId = req.query.teamId ? parseInt(req.query.teamId as string) : null;
let return_url = `${WEBAPP_URL}/settings/billing`;
if (!teamId) {
const customerId = await getStripeCustomerIdFromUserId(userId);
if (!customerId) return res.status(404).json({ message: "CustomerId not found" });
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
return res.redirect(302, billingPortalUrl);
}
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.getTeamByIdIfUserIsAdmin({
teamId,
userId,
});
if (!team) return res.status(404).json({ message: "Team not found" });
if (typeof req.query.returnTo === "string") {
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
if (safeRedirectUrl) return_url = safeRedirectUrl;
}
const stripeSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
res.redirect(302, stripeSession.url);
if (!teamMetadataParsed.success) {
return res.status(400).json({ message: "Invalid team metadata" });
}
if (!teamMetadataParsed.data?.subscriptionId) {
return res.status(400).json({ message: "subscriptionId not found for team" });
}
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
if (!subscription) {
return res.status(400).json({ message: "Subscription not found" });
}
if (!subscription.customer) {
return res.status(400).json({ message: "Subscription customer not found" });
}
const customerId = subscription.customer as string;
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
res.redirect(302, billingPortalUrl);
}
@@ -358,9 +358,14 @@ export class PaymentService implements IAbstractPaymentService {
paymentData: Payment,
eventTypeMetadata?: EventTypeMetadata
): Promise<void> {
const attendeesToEmail = event.attendeeSeatId
? event.attendees.filter(attendee => attendee.bookingSeat?.referenceUid === event.attendeeSeatId)
: event.attendees;
await sendAwaitingPaymentEmailAndSMS(
{
...event,
attendees: attendeesToEmail,
paymentInfo: {
link: createPaymentLink({
paymentUid: paymentData.uid,
@@ -27,3 +27,7 @@ export async function retrieveSubscriptionIdFromStripeCustomerId(
subscriptionId: subscription.id,
};
}
export async function getSubscriptionFromId(subscriptionId: string) {
return await stripe.subscriptions.retrieve(subscriptionId);
}
@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
export function getRequestedSlugError(error: unknown, requestedSlug: string) {
let message = `Unknown error`;
@@ -6,7 +6,7 @@ export function getRequestedSlugError(error: unknown, requestedSlug: string) {
// This covers the edge case if an unpublished team takes too long to publish
// and another team gets the requestedSlug first.
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2002
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
statusCode = 400;
message = `It seems like the requestedSlug: '${requestedSlug}' is already taken. Please contact support at help@cal.com so we can resolve this issue.`;
} else if (error instanceof Error) {
@@ -3,7 +3,7 @@ import {
PREMIUM_PLAN_PRODUCT_ID,
STRIPE_TEAM_MONTHLY_PRICE_ID,
PREMIUM_MONTHLY_PLAN_PRICE,
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID,
} from "./constants";
export const getPremiumMonthlyPlanPriceId = (): string => {
@@ -18,11 +18,13 @@ export function getPerSeatPlanPrice(): string {
return STRIPE_TEAM_MONTHLY_PRICE_ID;
}
export function getPhoneNumberMonthlyPriceId(): string {
if (!STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID) {
throw new Error("STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID env var is not set");
}
return STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID;
}
export function getPremiumPlanPriceValue() {
return "$29/month";
}
export function getPhoneNumberMonthlyPriceId(){
return STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID;
}
@@ -0,0 +1,19 @@
/**
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
export const VideoApiAdapterMap = process.env.NEXT_PUBLIC_IS_E2E
? {}
: {
dailyvideo: import("./dailyvideo/lib/VideoApiAdapter"),
huddle01video: import("./huddle01video/lib/VideoApiAdapter"),
jelly: import("./jelly/lib/VideoApiAdapter"),
jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"),
nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"),
office365video: import("./office365video/lib/VideoApiAdapter"),
shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"),
sylapsvideo: import("./sylapsvideo/lib/VideoApiAdapter"),
tandemvideo: import("./tandemvideo/lib/VideoApiAdapter"),
webex: import("./webex/lib/VideoApiAdapter"),
zoomvideo: import("./zoomvideo/lib/VideoApiAdapter"),
};
@@ -79,23 +79,11 @@ export const zoomUserSettingsSchema = z.object({
waiting_room: z.boolean(),
})
.nullish(),
meeting_security: z
.object({
waiting_room_settings: z
.object({
participants_to_place_in_waiting_room: z.number().optional(),
users_who_can_admit_participants_from_waiting_room: z.number().optional(),
whitelisted_domains_for_waiting_room: z.string().optional(),
})
.optional(),
})
.nullish(),
});
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
// append comma separated settings here, to retrieve only these specific settings
const settingsApiFilterResp =
"default_password_for_scheduled_meetings,auto_recording,waiting_room,waiting_room_settings";
const settingsApiFilterResp = "default_password_for_scheduled_meetings,auto_recording,waiting_room";
type ZoomRecurrence = {
end_date_time?: string;
@@ -187,12 +175,6 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
const userSettings = await getUserSettings();
const recurrence = getRecurrence(event);
const waitingRoomEnabled = userSettings?.in_meeting?.waiting_room ?? false;
const advancedWaitingRoomSettings = userSettings?.meeting_security?.waiting_room_settings;
const hasAdvancedWaitingRoomSettings =
waitingRoomEnabled &&
!!advancedWaitingRoomSettings &&
typeof advancedWaitingRoomSettings === "object" &&
Object.keys(advancedWaitingRoomSettings).length > 0;
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return {
topic: event.title,
@@ -218,9 +200,6 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
enforce_login: false,
registrants_email_notification: true,
waiting_room: waitingRoomEnabled,
...(hasAdvancedWaitingRoomSettings && {
waiting_room_settings: advancedWaitingRoomSettings,
}),
},
...recurrence,
};
@@ -579,10 +579,13 @@ function main() {
const url = new URL(document.URL);
embedStore.theme = window?.getEmbedTheme?.();
const autoScrollFromParam = url.searchParams.get("ui.autoscroll");
const shouldDisableAutoScroll = autoScrollFromParam === "false";
embedStore.uiConfig = {
// TODO: Add theme as well here
colorScheme: url.searchParams.get("ui.color-scheme"),
layout: url.searchParams.get("layout") as BookerLayouts,
disableAutoScroll: shouldDisableAutoScroll,
};
actOnColorScheme(embedStore.uiConfig.colorScheme);
@@ -79,6 +79,7 @@ function listKnownConfigProps() {
"ui.color-scheme",
"theme",
"cal.embed.pageType",
"ui.autoscroll",
];
return knownConfigProps;
}
+2
View File
@@ -32,6 +32,7 @@ export type UiConfig = {
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
layout?: BookerLayouts;
colorScheme?: string | null;
disableAutoScroll?: boolean;
};
declare global {
@@ -63,6 +64,7 @@ export type KnownConfig = {
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
"ui.autoscroll"?: "true" | "false";
theme?: EmbedThemeConfig;
// Prefixing with cal.embed because there could be more query params that aren't required by embed and are used for things like prefilling booking form, configuring dry run, and some other params simply to be forwarded to the booking success redirect URL.
// There are some cal. prefixed query params as well, not meant for embed specifically, but in general for cal.com
+26 -3
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
@@ -56,7 +57,11 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
}, [app.name, searchText]);
const handleAppInstall = () => {
if (isConferencing(app.categories)) {
if (isRedirectApp(app.slug)) {
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug });
return;
}
if (isConferencing(app.categories) && !app.concurrentMeetings) {
mutation.mutate({
type: app.type,
variant: app.variant,
@@ -148,7 +153,9 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
loading: mutation.isPending,
};
}
return <InstallAppButtonChild paid={app.paid} {...props} />;
return (
<InstallAppButtonChild paid={app.paid} isRedirect={isRedirectApp(app.slug)} {...props} />
);
}}
/>
)
@@ -170,7 +177,9 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
loading: mutation.isPending,
};
}
return <InstallAppButtonChild paid={app.paid} {...props} />;
return (
<InstallAppButtonChild paid={app.paid} isRedirect={isRedirectApp(app.slug)} {...props} />
);
}}
/>
)}
@@ -192,11 +201,25 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
const InstallAppButtonChild = ({
paid,
isRedirect = false,
...props
}: {
paid: App["paid"];
isRedirect?: boolean;
} & ButtonProps) => {
const { t } = useLocale();
if (isRedirect) {
return (
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon="external-link"
{...props}
size="base">
{t("visit")}
</Button>
);
}
// 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
if (paid) {
@@ -1,5 +1,5 @@
import type { Account, IdentityProvider, Prisma, User, VerificationToken } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { Account, IdentityProvider, User, VerificationToken } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { Awaitable } from "next-auth";
import type { PrismaClient } from "@calcom/prisma";
@@ -86,7 +86,7 @@ export default function CalComAdapter(prismaClient: PrismaClient): CalComAdapter
} catch (error) {
// If token already used/deleted, just return null
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") return null;
}
throw error;
+8 -2
View File
@@ -8,6 +8,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
import dayjs from "@calcom/dayjs";
import PoweredBy from "@calcom/ee/components/PoweredBy";
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
@@ -165,9 +166,14 @@ const BookerComponent = ({
} = calendars;
const scrolledToTimeslotsOnce = useRef(false);
const embedUiConfig = useEmbedUiConfig();
const scrollToTimeSlots = () => {
if (isMobile && !scrolledToTimeslotsOnce.current && timeslotsRef.current) {
if (
isMobile &&
!scrolledToTimeslotsOnce.current &&
timeslotsRef.current &&
!embedUiConfig.disableAutoScroll
) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- We are allowing it here because scrollToTimeSlots is called on explicit user action where it makes sense to scroll, remember that the goal is to not do auto-scroll on embed load because that ends up scrolling the embedding webpage too
scrollIntoViewSmooth(timeslotsRef.current, isEmbed);
scrolledToTimeslotsOnce.current = true;
@@ -10,7 +10,9 @@ export const SystemField = z.enum([
"rescheduleReason",
"smsReminderNumber",
"attendeePhoneNumber",
"aiAgentCallPhoneNumber",
]);
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
export const CAL_AI_AGENT_PHONE_NUMBER_FIELD = "aiAgentCallPhoneNumber";
export const TITLE_FIELD = "title";

Some files were not shown because too many files have changed in this diff Show More