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
emrysal 091327afee chore: release v5.6.8
Post release / release (push) Canceled after 0s
2025-08-28 11:15:53 +00:00
Anirban SinghaandKartik Saini f1d7d7f02a fix: Missing personal event types in All filter (#23343)
* fix: Missing personal event types in All filter

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

* chore: coderabbit suggestion

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

* chore: export function

* chore: Add test file

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

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-28 11:56:58 +02:00
Joe Au-Yeung 84bd4d3d38 Remove payment fee from chargeCard (#23411) 2025-08-28 08:07:52 +00:00
Rudra Gupta 0367cf8e41 docs: update api step 1 path in self hosting documentation (#23393) 2025-08-27 16:37:15 +00:00
Kartik SainiandAnik Dhabal Babu afea724e4d fix: improve cancellation email of reschedule RR booking (#17768)
* fix: improve cancellation email of reschedule RR booking

* fix failing test

* fix: uses same lucky user when rescheduling RR booking also has a fixed host

* chore

* chore

* better message in the cancelled email

* fix

* fix

* add booker rescheduled in reassigned email to old host

* test

* add reassigned subtite

* fix: use same lucky user when reschedule RR booking has a fixed host

* fix: test

* fix: subtitle

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-27 13:28:33 -03:00
Calcom BotandLingo.dev a4915184cd feat: update translations via @LingoDotDev (#22894)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-08-27 13:26:14 -03:00
b7a7797293 feat: add paid, userEmail, userName, and rating filters to insights page (#23299)
* feat: add paid, userEmail, and userName filters to insights page

- Add paid filter as MULTI_SELECT with 'Paid'/'Free' options
- Add userEmail filter as TEXT for searching user emails
- Add userName filter as TEXT for searching user names
- Update DummyTableRow type to include new filter columns
- Add backend filtering logic in buildColumnFilterCondition method
- Use existing columns from BookingTimeStatusDenormalized view
- Follow established patterns from /bookings page implementation

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

* fix: change paid filter from MULTI_SELECT to SINGLE_SELECT

- Updated useInsightsBookings.ts to use ColumnFilterType.SINGLE_SELECT for paid filter
- Modified buildColumnFilterCondition to handle single select with isSingleSelectFilterValue
- Simplified backend logic to handle single boolean value instead of array

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

* fix paid filter

* feat: add rating filter to insights page

- Add rating field to DummyTableRow type
- Add rating column with SINGLE_SELECT filter type
- Add rating options 1-5 in faceted unique values
- Add rating filtering logic in backend service
- Rating filter allows filtering bookings by 1-5 star ratings

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

* feat: change rating filter to NUMBER type

- Update rating filter from SINGLE_SELECT to NUMBER in useInsightsBookings.ts
- Remove rating options from useInsightsBookingFacetedUniqueValues.ts since NUMBER filters don't need predefined options
- Add isNumberFilterValue import and implement number filter logic in InsightsBookingBaseService.ts
- Support eq, gt, gte, lt, lte operators for rating number filter

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

* refactor: use makeSqlCondition utility for filter logic

- Import makeSqlCondition from @calcom/features/data-table/lib/server
- Replace manual SQL condition building with makeSqlCondition utility for userEmail, userName, and rating filters
- Maintain existing paid filter logic for boolean conversion
- Follow established pattern from InsightsRoutingBaseService
- Cleaner, more maintainable code with consistent filter handling

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

* add missing texts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-27 16:00:10 +00:00
Lauris Skraucis d0e59f1824 fix: platform confirmed/declined meeting attendee reschedule email url (#23335)
* fix: platform confirmed/declined meeting attendee reschedule email url

* chore: bump platform libraries
2025-08-27 14:35:10 +00:00
Lauris SkraucisandAnik Dhabal Babu 67befea66e fix: dutch appearing in booking field name (#23364)
* fix: dutch appearing in booking field name

* fix: tests

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-27 19:43:37 +05:30
Morganandsupalarry f18ebc4d67 chore: create standalone i18n utility for platform libraries (#23386)
* chore: create i18n for platform libraries

* fixup! chore: create i18n for platform libraries

* refactor: use previous version of i18n

* Revert "refactor: use previous version of i18n"

This reverts commit 0181ae1018d99ea6d5455d6dde5f3727e70422e1.

* refactor: use previous version of i18n

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-08-27 10:55:57 -03:00
Eunjae LeeandDevin AI 5324832f13 refactor: extract rating values and emojis into reusable constants (#23340)
* refactor: extract rating values and emojis into reusable constants

- Create packages/lib/rating.ts with RATING_OPTIONS array and validation utilities
- Update bookings-single-view.tsx to use extracted constants
- Add TypeScript interfaces for type safety
- Enable reuse of rating constants across the codebase

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

* refactor: move rating utilities to bookings feature directory

- Move packages/lib/rating.ts to packages/features/bookings/lib/rating.ts
- Update import path in bookings-single-view.tsx to use new location
- Maintain domain-specific organization for booking-related utilities

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

* fix: resolve linting issues in event-types.tsx

- Fix quote style consistency (single to double quotes)
- Add missing trailing comma
- Resolve pre-commit hook formatting changes

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-27 15:54:52 +02:00
Benny Joo 2d9162a4dd perf: use promise all when possible in RSCs (#23384) 2025-08-27 08:52:27 -03:00
Yadong (Adam) Zhang d9443162ef fix: replace useTranslation with useLocale in AppCard component (#23379) 2025-08-27 19:09:03 +09:00
e8dbab99eb feat: replace window.todesktop with official @todesktop/client-core package (#23354)
* feat: replace window.todesktop with official @todesktop/client-core package

- Import platform from @todesktop/client-core in _document.tsx
- Pass isDesktopApp result via window.calIsDesktopApp to stringified function
- Update applyToDesktopClass to use passed-in value instead of window.todesktop
- Maintains existing architecture while using official package API

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: move ToDesktop detection to client-side to resolve SSR issue

- Remove server-side call to platform.todesktop.isDesktopApp() which always returns false
- Move detection logic to client-side stringified script using same logic as official package
- Maintain client-side detection while ensuring proper functionality in ToDesktop environment

Co-Authored-By: peer@cal.com <peer@cal.com>

* feat: use official platform.todesktop.isDesktopApp() function

- Import platform from @todesktop/client-core in _document.tsx
- Call platform.todesktop.isDesktopApp() directly in stringified script
- Replace duplicated logic with official package function call
- Maintain client-side detection while using official API

Co-Authored-By: peer@cal.com <peer@cal.com>

* feat: properly use official platform.todesktop.isDesktopApp() function

- Call platform.todesktop.isDesktopApp() outside stringified context
- Pass result as template variable to avoid SSR issues
- Now actually uses the official @todesktop/client-core package
- Eliminates duplicated detection logic as requested

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: add SSR safety guards around platform.todesktop.isDesktopApp() call

- Wrap platform.todesktop.isDesktopApp() in try/catch to prevent SSR crashes
- Return false as fallback if library touches browser globals during SSR
- Addresses keithwillcode's comment about SSR safety concerns

Co-Authored-By: peer@cal.com <peer@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-27 09:21:37 +00:00
cdcc04c8ff refactor: extract PanelCard from ChartCard for reusability (#23360)
* feat: extract PanelCard from ChartCard for reusability

- Create base PanelCard and PanelCardItem components in UI package
- Refactor ChartCard to use PanelCard as foundation while keeping legend functionality
- Add headerActions prop to PanelCard for extensibility
- Maintain backward compatibility for existing ChartCard usage
- Export PanelCard and PanelCardItem from UI card components

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

* chore: update yarn.lock after dependency resolution

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

* refactor: rename prop from actions to headerContent

- Rename 'actions' prop to 'headerContent' in PanelCard component
- Update ChartCard to use new 'headerContent' prop name
- More accurate naming since content can be text or other elements, not just buttons

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

* clean up

* revert yarn.lock

* fix type error

* type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-27 06:18:44 -03:00
Anwar SadathandUdit Takkar 740a1c991e fix: Role not getting updated immediately in edit profile modal (#23236)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-27 09:03:20 +00:00
353 changed files with 16110 additions and 3780 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
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
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
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.333",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.335",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -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,6 +520,7 @@ export const systemBeforeFieldName: NameSystemField = {
name: "fullName",
type: "text",
required: true,
label: "your_name",
},
],
},
@@ -292,7 +292,7 @@ describe("transformBookingFieldsInternalToApi", () => {
isDefault: true,
required: true,
disableOnPrefill: false,
label: undefined,
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,6 +725,7 @@ describe("Organizations Event Types Endpoints", () => {
isDefault: true,
type: "name",
slug: "name",
label: "your_name",
required: true,
disableOnPrefill: false,
},
@@ -822,6 +823,7 @@ describe("Organizations Event Types Endpoints", () => {
type: "name",
slug: "name",
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 }}
/>
</>
);
}
@@ -21,11 +21,13 @@ const Page = async () => {
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
const [connectedCalendars, installedCalendars] = await Promise.all([
calendarsCaller.connectedCalendars(),
appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
}),
]);
return (
<CalendarListContainer connectedCalendars={connectedCalendars} installedCalendars={installedCalendars} />
);
@@ -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;
@@ -21,9 +21,9 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const [t, _headers, _cookies] = await Promise.all([getTranslate(), headers(), cookies()]);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
@@ -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,11 +46,62 @@ 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 = await getCachedAttributes(org.id);
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
const roles = await 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,
@@ -57,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;
}
+20 -1
View File
@@ -30,6 +30,10 @@ const querySchema = z.object({
const decryptedSchema = z.object({
bookingUid: z.string(),
userId: z.number().int(),
platformClientId: z.string().optional(),
platformRescheduleUrl: z.string().optional(),
platformCancelUrl: z.string().optional(),
platformBookingUrl: z.string().optional(),
});
// Move the sessionGetter function outside the GET function
@@ -64,7 +68,14 @@ async function handler(request: NextRequest) {
symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")
);
const { bookingUid, userId } = decryptedSchema.parse(decryptedData);
const {
bookingUid,
userId,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
} = decryptedSchema.parse(decryptedData);
const booking = await prisma.booking.findUniqueOrThrow({
where: { uid: bookingUid },
@@ -97,6 +108,14 @@ async function handler(request: NextRequest) {
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
platformClientParams: platformClientId
? {
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
}
: undefined,
});
} catch (e) {
let message = "Error confirming booking";
-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,
},
});
}
@@ -62,7 +62,7 @@ export const applyTheme = function () {
export const applyToDesktopClass = function () {
try {
const onReady = () => {
if (typeof window !== "undefined" && window.todesktop && document.documentElement) {
if (typeof window !== "undefined" && window.calIsDesktopApp && document.documentElement) {
document.documentElement.classList.add("todesktop");
} else if (document.documentElement) {
return;
@@ -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 {
SystemField,
TITLE_FIELD,
} from "@calcom/features/bookings/lib/SystemField";
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks";
import { APP_NAME } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
@@ -179,14 +180,13 @@ export default function Success(props: PageProps) {
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
const [comment, setComment] = useState("");
const parsedRating = rating ? parseInt(rating, 10) : 3;
const currentUserEmail =
searchParams?.get("rescheduledBy") ??
searchParams?.get("cancelledBy") ??
session?.user?.email ??
undefined;
const defaultRating = isNaN(parsedRating) ? 3 : parsedRating > 5 ? 5 : parsedRating < 1 ? 1 : parsedRating;
const defaultRating = validateRating(rating);
const [rateValue, setRateValue] = useState<number>(defaultRating);
const [isFeedbackSubmitted, setIsFeedbackSubmitted] = useState(false);
@@ -1026,61 +1026,20 @@ export default function Success(props: PageProps) {
) : (
<>
<div className="my-3 flex justify-center space-x-1">
<button
className={classNames(
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
rateValue === 1
? "border-emphasis bg-emphasis"
: "border-muted bg-default opacity-50"
)}
disabled={isFeedbackSubmitted}
onClick={() => setRateValue(1)}>
😠
</button>
<button
className={classNames(
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
rateValue === 2
? "border-emphasis bg-emphasis"
: "border-muted bg-default opacity-50"
)}
disabled={isFeedbackSubmitted}
onClick={() => setRateValue(2)}>
🙁
</button>
<button
className={classNames(
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
rateValue === 3
? "border-emphasis bg-emphasis"
: " border-muted bg-default opacity-50"
)}
disabled={isFeedbackSubmitted}
onClick={() => setRateValue(3)}>
😐
</button>
<button
className={classNames(
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
rateValue === 4
? "border-emphasis bg-emphasis"
: "border-muted bg-default opacity-50"
)}
disabled={isFeedbackSubmitted}
onClick={() => setRateValue(4)}>
😄
</button>
<button
className={classNames(
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
rateValue === 5
? "border-emphasis bg-emphasis"
: "border-muted bg-default opacity-50"
)}
disabled={isFeedbackSubmitted}
onClick={() => setRateValue(5)}>
😍
</button>
{RATING_OPTIONS.map((option) => (
<button
key={option.value}
className={classNames(
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
rateValue === option.value
? "border-emphasis bg-emphasis"
: "border-muted bg-default opacity-50"
)}
disabled={isFeedbackSubmitted}
onClick={() => setRateValue(option.value)}>
{option.emoji}
</button>
))}
</div>
<div className="my-4 space-y-1 text-center">
<h2 className="font-cal text-lg">{t("submitted_feedback")}</h2>
@@ -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", () => ({
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.6.7",
"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",
@@ -76,6 +75,7 @@
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@todesktop/client-core": "^1.20.0",
"@types/turndown": "^5.0.1",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
+11 -1
View File
@@ -1,3 +1,4 @@
import { platform } from "@todesktop/client-core";
import type { IncomingMessage } from "http";
import { dir } from "i18next";
import type { DocumentContext, DocumentProps } from "next/document";
@@ -5,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 };
@@ -37,6 +38,14 @@ class MyDocument extends Document<Props> {
const newLocale = this.props.newLocale || "en";
const newDir = dir(newLocale);
const isDesktopApp = (() => {
try {
return platform.todesktop.isDesktopApp();
} catch {
return false;
}
})();
return (
<Html
lang={newLocale}
@@ -49,6 +58,7 @@ class MyDocument extends Document<Props> {
dangerouslySetInnerHTML={{
__html: `
window.calNewLocale = "${newLocale}";
window.calIsDesktopApp = ${isDesktopApp};
(${applyTheme.toString()})();
(${applyToDesktopClass.toString()})();
`,
+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);
};
+101 -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,9 +3576,20 @@
"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",
"phone": "Phone",
"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,
@@ -711,10 +711,12 @@ export function expectSuccessfulRoundRobinReschedulingEmails({
emails,
newOrganizer,
prevOrganizer,
bookerReschedule,
}: {
emails: Fixtures["emails"];
newOrganizer: { email: string; name: string };
prevOrganizer: { email: string; name: string };
bookerReschedule?: boolean;
}) {
if (newOrganizer !== prevOrganizer) {
vi.waitFor(() => {
@@ -738,6 +740,19 @@ export function expectSuccessfulRoundRobinReschedulingEmails({
`${prevOrganizer.email}`
);
});
// if booking is rescheduled by booker, old organizer should recieve reassigned emails
if (bookerReschedule) {
vi.waitFor(() => {
expect(emails).toHaveEmail(
{
heading: "event_request_reassigned",
to: `${prevOrganizer.email}`,
},
`${prevOrganizer.email}`
);
});
}
} else {
vi.waitFor(() => {
// organizer should receive rescheduled emails
+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
@@ -94,7 +94,7 @@ We recommend using the admin UI/wizard instead of the seeder to enable app store
Copy the .env files from their respective example files:
```
cp apps/api/.env.example apps/api/V2/.env
cp apps/api/v2/.env.example apps/api/v2/.env
cp .env.example .env
```
+89 -16
View File
@@ -122,7 +122,7 @@ checksums:
already_signed_up_for_this_booking_error: ad057090220b6b8adf9d4e6bd0072a1a
hosts_unavailable_for_booking: d26a4fa6c90f863ec4b955badfd43369
fixed_hosts_unavailable_for_booking: e379ff2fb3df7de4de66f7e11f01181f
round_robin_hosts_unavailable_for_booking: 612117ccd8929e89fb6086c0376f4f36
round_robin_host_unavailable_for_booking: 680187a6d606c4d4c587686ad0609a04
help: 62712a1f2fcef75e399e9a9a9ddbd495
price: 6e3258d4cf5835177d174a5259a9cfd6
paid: 5e2fa7af50cd5d877d9c54fd87bb3bb5
@@ -280,6 +280,7 @@ checksums:
number: 2789f8391f63e7200a5521078aab017d
checkbox: 6c83cdf357556d2be8141286a6129ca8
is_required: b6c231d5d1a8dfe37615d1efd38ed8e0
make_field_required: 9b28825d70bcd8017665101d2b48c8f5
required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
optional: 396fb9a0472daf401c392bdc3e248943
input_type: df4865b5d0a598a8d7f563dcec104df5
@@ -351,11 +352,12 @@ checksums:
available_apps_lower_case: bdc6d9add2c4aadff6367146844ab26b
available_apps_desc: 19b77fdde8bf57dd96d534dfdadf8784
fixed_host_helper: 0ce1b6682365fb5b90835f54f3695e08
round_robin_groups_helper: d8ea5091f37e1b0c555cb39d3f8ddd5d
round_robin_helper: 7c1df95abb4b36b2eb4d8a199ffd771f
check_email_reset_password: 5e9935930a0028c0a3fe9ce514486c4b
finish: ffa7a10f71182b48fefed7135bee24fa
organization_general_description: 7291b3669f5b70ca67b4f1cbdcd624bb
few_sentences_about_yourself: d7654a6ea56041d9156f90ccb7744d3d
few_sentences_about_yourself: 2ca32d3ec72d9564171d4ac4c03b28ae
nearly_there: 816b60667173ed8c09cb34c8ae24d683
nearly_there_instructions: b6219de27d3085d0f0e311d8aad1473e
set_availability_instructions: f14d51965efa0832b592a2996b8cf183
@@ -368,7 +370,7 @@ checksums:
continue_with: 0ae1bdfffc35db9ab647cabde5a1e903
connect_your_calendar: 5cd95797e99cd567bd62586315c8d258
connect_your_video_app: 6b9ae7571e81a24036762ec2e911016f
connect_your_video_app_instructions: 55ca3584c4cf929f74264901bda5c91a
connect_your_video_app_instructions: 6cca9702f852606f2ae44b610083b7b0
connect_your_calendar_instructions: e5ba48efa80dc59f9ba8c63af9a14c9a
set_up_later: ef3f9b469acc29421fee2cbd96496aec
current_time: 9b305d24a89fd53c07b95b1026c4013b
@@ -873,6 +875,10 @@ checksums:
create_team: d3cba7d5fb6932d919f9dfad7a1e9d9f
name: 9368b5a047572b6051f334af5aa76819
nameless_team: 44bfe8692c3839f1fd10459239409a30
oauth_clients: 2227d1efbcc881c6950c9f89defc3ef6
oauth_clients_description: a19c9bec065092b90b35a6c20853f0a9
create_oauth_client: 051050b5d57086f194937ad58d9a7e4e
create_oauth_client_description: b316645e558e918a58f0fe02996944e8
oauth_client_deletion_message: 1683b64379f8e873199f3fe43edaa1f8
create_new_team_description: aea7bc114f52617f8663748eec96d6a4
create_new_team: d802300d68d2ca2d7c68b1e87d1ca620
@@ -928,7 +934,7 @@ checksums:
add_attendees: 6346e21f1a597f17b1d8274b01009320
show_advanced_settings: e62afb3ad1a9c06c7b4645644c85c8e0
event_name: e9ee5be0bc00804cc48c982d2ad1f849
event_name_in_calendar: 2def5f7fe55e437e3c629d72697e1e19
event_name_in_calendar: a0ba9c8a87a892b060579bd2dc697a43
event_name_tooltip: a00ef459ffb3ee9c2660b4125a9afdea
meeting_with_user: 14ce863aa52d1a7342edab871a5d38d6
additional_inputs: 1d54308ce5acc12e2d8bd53e5d3ff055
@@ -944,7 +950,7 @@ checksums:
add_input: b59c6567a19474e38d15f7a01e751430
disable_notes: 26e3eaffcd889bf9d62be546080b3eaa
disable_notes_description: d778488f6709a75addc93c0eee488764
requires_confirmation_description: 8e79eadfbbbafbf2963da3fbcb9122f1
requires_confirmation_description: b2d07cb323e52a6045917975f9bdc840
requires_confirmation_will_block_slot_description: 07d2ec2d2ad1123b833f63f6d675cd3d
recurring_event: 64081e2a29e23cfc8eb421a44565da0c
recurring_event_description: 6652c7b8ad3562c04d4ace180a96bf09
@@ -995,7 +1001,7 @@ checksums:
quick_chat: 5e2327c480f35a5ea63133a27696fa03
add_new_team_event_type: 91310d2339fa6f04069a5584d4052740
add_new_event_type: 8e5daeaa02efd6fd30f6f68667d20b98
new_event_type_to_book_description: 4a41017f9db37a4d15d22cc18bfaa58e
new_event_type_to_book_description: cd7ccfe4b700ae97d1753a2ad21ef516
length: 754cca6fb827c7601a9807af9a307e76
minimum_booking_notice: f6bc7f365a72bb841a9cda7ced48315f
offset_toggle: 9c012a72cacc90d6ff9ffce07785df4c
@@ -1020,6 +1026,7 @@ checksums:
category_apps: 0d34fb06414435ed78d5d5bea8798f5a
app_store: 197ca35860dab533daae7095f7783e8f
app_store_description: 6abd9249a0ada5e759aaa39d47bff06b
connect_your_video: 903ba65f688ce0f6f9aff545b20224cf
settings: 8df6777277469c1fd88cc18dde2f1cc3
event_type_moved_successfully: 617ccde0ea645ec6eba51e07e8d6f945
next_step_text: f1b1016308534343ff1506a52960a7da
@@ -1234,7 +1241,7 @@ checksums:
make_team_private_description: c48915c8357c3687506183e92e6aa155
you_cannot_see_team_members: dc7a5d20692b36f17081d7a10e66821c
you_cannot_see_teams_of_org: c6dafa34320944aa57a5f4053541c092
allow_booker_to_select_duration: eeb542d3b07c062d8de9630bcaf592fc
allow_booker_to_select_duration: b93ab67d353eb91e9220b6b92fd1533c
impersonate_user_tip: b185a4d43f5d2bd47d90c3118a9e6a4a
impersonating_user_warning: 2df022b91992a4c819abee8ca62cdf1c
impersonating_stop_instructions: fc01010bfe9c16de03a6311a3a146f7c
@@ -1490,7 +1497,7 @@ checksums:
connect_your_calendar_and_link: 39a00247fd714fb2e6a94f8577f32176
default_calendar_selected: a261712074af618d1d222e95dbbb0c5c
hide_from_profile: 5a9e2f64b739f6ac6023702e7ef74da4
event_setup_tab_title: 547d34044a5f032fd65b3ce45617dc79
basics: 1f5b53b9904ec41d49c1e726e3d56b40
availability_not_found_in_schedule_error: ebe0dfb691e2963cc8a141e7c153c6d1
event_limit_tab_title: b86fd2e3c3ad5d514b55ad8691bcf7be
event_limit_tab_description: 0a6df44e16911fed68d3757e82486f69
@@ -1520,7 +1527,8 @@ checksums:
settings_updated_successfully: dc24a87d79e378ee848a9f5533190dc8
error_updating_settings: 858e121bc27bdfcbc7e16ab2a2753429
personal_cal_url: 1d8cd97fbd9f7116174aca6888207c5e
bio_hint: 07d73bb2679414939ac6b6f6ab8e02ef
bio_hint: e99af20bf39bffd30ec892195c5fcf1d
finish_and_start: d6cca946ff6a25e96b9c5cc17703e8ed
user_has_no_bio: ed00c0588b37b5fb565baf7736ace2b1
bio: 9c27cde7f812e40bb94f725ea488ffd9
delete_account_modal_title: f5f7b88ff3122fb0d3a330e2b99d7e3d
@@ -1576,8 +1584,10 @@ checksums:
we_just_need_basic_info: b464a1de99013d863016889c558b5f34
skip: b7f28dfa2f58b80b149bb82b392d0291
do_this_later: 1e6fc4c9ad0fe2bd0ac11dde8b6825ce
set_availability_getting_started_subtitle_1: 78e33d8cd546da46f0af73317bebe7f8
set_availability_getting_started_subtitle_2: 1fd0220731b4665a47d6e78c7a28178a
set_availability_getting_started_subtitle_1: bf55c71265463c32aa1f6c49a9f99407
set_availability_getting_started_subtitle_2: 2aa9c18283f041f813fa8c5c1f5e8153
connect_calendar_first: 4be230f82bfb4bd0df04ce84dab3f34d
complete_profile: 061e1acc1b9ebad9de09fd5626e813c7
connect_calendars_from_app_store: 265b755dcbae64a5a2257dd4dd47c8c6
connect_conference_apps: 87f2c177e19cc26dd0b376df7304e51e
connect_calendar_apps: 46f91b99469fcadbd49955b7b1e7df15
@@ -1598,7 +1608,9 @@ checksums:
event_duration_info: 2ed8fb3b9b152602e140a9c4e34ac9bd
event_time_info: a000c4195df3733507313d8312f7f1d5
event_type_not_found: 51890a3a3fd93d173795a3f3196da032
location_variable: 036cbdfe32bbe9ad67b5d06ef6a02a16
location_info: 38a03a8e5d83713221944a25250d1e51
additional_notes_variable: 416243d69874a5fd915db4c16a5311cc
additional_notes_info: 17b42b1273dd8e0c25b8c1fe1973229a
attendee_name_info: 83fd36e412cf5e450ff7e768a4c62329
organizer_name_info: f6dff61f5beeed2681c04a4e89ea4029
@@ -1617,7 +1629,7 @@ checksums:
transcript_from_previous_call: 7485bc94e7ca0e7b2f9cefa8f5b12168
you_can_download_transcript_from_attachments: bd1f8b66fa7a44eeed9c113112a042e5
link_valid_for_12_hrs: 7701a0f7bdc1ca82a14e6fa28583ac74
link_valid_for_12_hrs_description: 4b38956402dac1b1a12515a06a957940
link_valid_for_12_hrs_description: 7f7018ae10df2db2f2afa35a738433e3
create_your_first_form: 5d33316ece53f9ffddba51418869d7aa
create_your_first_form_description: deafa03d444fc9af9c16f48408d8048c
create_your_first_webhook: cbb0a8e44a610282d7c63fef7219c0be
@@ -1833,6 +1845,8 @@ checksums:
edit_event_type: 3f77349b289c806b6c874de9a13445b0
only_admin_can_see_members_of_org: d20dcbe43e5c10edbfd1d20316474fe3
only_admin_can_manage_sso_org: e2a7e6af650c972af029c8145d1e0254
only_admin_can_manage_directory_sync: 0af7f53455411024be3ebe47670d9f2d
only_admin_can_manage_oauth_clients: 46f190d296bd8744125ad348ee70db23
collective_scheduling: 17c7a9426e7ff88e7ba0034ae1bc7348
make_it_easy_to_book: eca39589fe5c4135a5efb30bb51ab0ab
find_the_best_person: cddde4af1e1cf65e25238c64d51b83d7
@@ -2105,6 +2119,7 @@ checksums:
can_you_try_again: 540ea892abd7c38c02d1ec65ad3623a8
verify: 6ad4aad279b2411ee9bf24a43e37ae03
timezone_info: 731aaf7a27ec27c4f22ec7b91220c80c
timezone_variable: 370780b854bdbef55320f977a4fb9ddb
event_end_time_variable: 7466e7c857e5addbcdde666c160e84b0
event_end_time_info: dff59c43944ee9ec4f0b14788f916ead
cancel_url_variable: 07203aef11c344e47a12a8dc3434a914
@@ -2169,6 +2184,10 @@ checksums:
bookings_by_hour: c2d03845275d48df37246753ecf5310c
most_booked_members: 10906b2191a464027dc36b87feab9ff0
least_booked_members: b66584dbcaa7c3d5d8006bcc5e51f8e1
most_bookings_scheduled: c775d28a95edaaa9176e04159a1fe919
least_bookings_scheduled: 8b1d5e487b36b8fd817558e1020e3b4a
most_bookings_completed: 404ea28c3bf288b5833c2072bf6ea185
least_bookings_completed: 1700a1001e470b71dff9a0928e0a30ea
events_created: ca9ae014a15ab692f4b866db526068f6
events_completed: ebda016459b52c4376f2a33427c95ed5
events_cancelled: 2f748417a70a1375d97ea0df78459ea0
@@ -2242,6 +2261,8 @@ checksums:
floating_button_trigger_modal: e9e5f0fbc5896de26ca983a55944de14
pop_up_element_click: d91c699d48af89894ab53caaacf93ec0
open_dialog_with_element_click: fa71b924f11690204e65301eadb2dc7c
use_my_own_form: 54f4162c46467af501906cb16a894759
use_our_headless_routing_api: d22d13e0cbad4b50ecc68c811c61cbc8
need_help_embedding: ffcbb2c760751a559459730fe54f90b5
book_my_cal: 001dc49a4f30635ba28ed75c25b48f2e
first_name: 9a22c348ecef9bbdb8934eb2e6fb873c
@@ -2718,7 +2739,9 @@ checksums:
review: 299f75db25382980b2895622d7712927
reviewed: 7387d3303c204db754abedb340f3e641
unreviewed: ef38be566a419c5e25f93e7928998533
rating_url_variable: 71bb4cc12088ef7b1d28750ec144b506
rating_url_info: 81730c6478b71022bf3292e7ee53e298
no_show_url_variable: e54d59994a514e72234eaf206879ca56
no_show_url_info: 3dd3b9a56e73b75e9c6d1d88e5772412
no_support_needed: f34ec5e086847c4cb921a4a05f8ac5e0
hide_support: e27577b695e1d51833cb5d16d08e6791
@@ -2908,8 +2931,10 @@ checksums:
row: 49da3a86e7ec17952111c2b50ca30855
rows: bab3e3eb4561e5e6dad4b59b34622e73
attendee_timezone_variable: 12ddea7f482713f29699e0a6283f0f52
event_start_time_in_attendee_timezone_variable: 8cc2234a6faeb881fe136f052d93d436
event_start_time_in_attendee_timezone_info: 8cc2234a6faeb881fe136f052d93d436
event_end_time_in_attendee_timezone_info: 82ffc6118c662094eb130b74a1e59627
event_end_time_in_attendee_timezone_variable: 82ffc6118c662094eb130b74a1e59627
salesforce_create_event_on_contact: 82fe69b4ba08b1cb9c6465dc18f968b5
salesforce_owner_name_to_change: 000522ab557b02cfe4a5d49787259ef9
no_filter_set: 5167f195fa1a278506d613d550b578f0
@@ -2973,8 +2998,8 @@ checksums:
edit_team_member_weights: 66ba6fc048187689fc71b00be1d1ca0f
weights_updated_successfully: e5ad5ff71665572c8512f1cb4112359c
require_confirmation_for_free_email: 669a3c050cc065e85df79e0e7f12bbfd
exclude_emails_that_contain: d43255c873c75c8fe1adcb8f8b7af914
require_emails_that_contain: 54623674a8bf704fd44299c59eb31363
exclude_emails_that_contain: e020008bbce1967bda4b3e3fff586d66
require_emails_that_contain: 582d626d96e770c4d40ebe9afd2cda9e
exclude_emails_match_found_error_message: 4ca9429a41d63e53caffeceb0e4229f7
require_emails_no_match_found_error_message: 73b9ed7c447256401bb273edb7618025
search_columns: 4ea704f7b20d0f19dea8a5a121a639fa
@@ -3109,7 +3134,7 @@ checksums:
inactive_team_plan_description: 15de96578b521a59f65b7fa69e9a4888
limited_access_trial_mode: c501618906970336bf51e5d715efed8d
referral_program: bc858f75f37326cc8cb8645d3bca6e4c
referral_text: 895c43fcfaf7081a20d364994264abe1
referral_text: be5af34440793e93d8cc4fdd4798ebfb
dub_disabled_error_message: 5f77315bfefcd9e3268a54ea96c9a7de
uid: 379cc5edfadebfc2d792e341bf7a190b
link: 60f0aa33e70ca6df3b6659802b16f433
@@ -3277,6 +3302,7 @@ checksums:
pbac_resource_booking: b6c73c2c1406f1f5fc13ef027ddb9352
pbac_resource_insights: 1e88b2537ba6966f2a90b0def1ce1ddc
pbac_resource_role: 21834ee85d5936bc0b262d5d5ddd58e3
pbac_resource_workflow: b0c9c8615a9ba7d9cb73e767290a7f72
pbac_action_all: 8955f3bda3a57897951eaffc3772ba2f
pbac_action_create: 757ccd28dd533ff3a933355273c1e32a
pbac_action_read: 36a9b5e3dc153c036d320460d72a03c3
@@ -3299,10 +3325,19 @@ checksums:
error_creating_role: 4d3c36c243381aa68d671ae9c58265cd
error_updating_role: bb0758044b466e260c9bb3704b4d115f
pbac_desc_create_roles: 1c90a3924c51b4ebf20c9512abe8b0d2
pbac_resource_attributes: 86d0ae6fea0fbb119722ed3841f8385a
pbac_desc_view_organization_attributes: 02e8205586f5f2cf78f1b47a701b4cb8
pbac_desc_update_organization_attributes: 9b57dbaf7d65e9e10715abb55b5f29d5
pbac_desc_delete_organization_attributes: 9ba51430aabc555dbec71f9a147025da
pbac_desc_create_organization_attributes: f632e6d5ce49a78f164c34ecf91fc244
pbac_desc_view_roles: b6a40cea14b8cf3ff14b5623035c4d0a
pbac_desc_update_roles: beadef54fb8663867bd85ff4e148c518
pbac_desc_delete_roles: 8d00c0ab49a7fa27af90b81b2baad8c6
pbac_desc_manage_roles: 69bf3bb5f8800c75af2b27acadc79916
pbac_desc_create_workflows: a38ab5f1cdd6f3bd9a1128dfa9d445ce
pbac_desc_view_workflows: 4e1394ded2ee6fe6d87601a4915b08d4
pbac_desc_update_workflows: aecf636063ef3b8a5ece8d6381e053f0
pbac_desc_delete_workflows: fabfe2406f079e08cf4b017afca797bd
pbac_desc_manage_workflows: 9ff3998b178daad7fa575d1277345d31
pbac_desc_create_event_types: 0a58f83b33aa6ba7784de540b3107bbe
pbac_desc_view_event_types: 390e116e8ff1ec3db6dc5079aaa80b7b
pbac_desc_update_event_types: 6be520e46fac127d0286624dba27b0f7
@@ -3340,6 +3375,7 @@ checksums:
use_booker_timezone_info: a4229154c78943a19c36121824f5ecc2
load_balancing_warning: 32253dc7d247d56370c67f80da8b9cff
rr_load_balancing_disabled: 6503dbd3fde5a2caf7d3d91d3003202e
rr_load_balancing_disabled_with_groups: 21faa6951621aae82853766c44de7ede
round_robin_settings_description: e3700766347dce87bf8bf5829dd7a6b3
round_robin_settings_updated_successfully: d5af777266614256a0f23338515323dc
booking_creation_time: cb184643434b260638743678fe6e5948
@@ -3356,6 +3392,7 @@ checksums:
booking_not_allowed_by_restriction_schedule_error: e4505da88d555ca9486a1113b5ca94a6
restriction_schedule_not_found_error: a0ee65030e040bc733e822f33e4aa678
converted_image_size_limit_exceed: 7718a7525d55152117a3bb080acaf44d
add_group: 6b56dcc409f4685797a928f4c3c0fb02
routing_funnel: f1a521e5e4e4468bb2249a6ea1facc39
routing_funnel_total_submissions: 8de3288bfb92dc66fe2b2f72b6e34ffc
routing_funnel_successful_routings: 95db57c8515a59cfa40c43e47b228420
@@ -3388,6 +3425,7 @@ checksums:
license_key_saved: bce8ce1a964cb0c0c7cbbfdcc6523485
timezone_mismatch_tooltip: a1a8ffbc355ed721fe7adaaa8a521251
failed_bookings_by_field: 2411b6ea0b93077d317c10b91b82a000
failed_bookings: 8e46371f24c5d22fd4a1254915a2c117
event_type_no_hosts: bc0fdbcb0db11dbd580e5d3b9cef600b
cache_status: 8dac90fe8cbd256b045933d986f8d320
cache_last_updated: db9a5bb5660885ef09949d310b1e5612
@@ -3415,4 +3453,39 @@ checksums:
usage_based_expiration_description: e4f9ae021e1071d20840008f332675ca
usage_based_generic_expiration_description: 4f925bb5809c561a54b058050a74a9dc
usage_based_expiration_description_plural: ee87fd5c078aa1e78117c7e27b493cbc
webhook_trigger_event: c53db8127faf8f90ce9b59ff51ba69de
webhook_created_at: d076c414e2d53c0c9fbd74e4af4eea95
webhook_type: fef7438e12f0d54e161afddd3d035031
webhook_title: aadf152d45b489f9ea2abeb55cd232db
webhook_start_time: 294866d2c6a0929d39c56793c33df16e
webhook_end_time: a72052fa163d4ad56a4fd8cc7f9f2c20
webhook_description: d31ba739993e5a757eddce684493dcf6
webhook_location: c0b3e1370a0b95ac0907384ef5ffc633
webhook_uid: e221c2c66821f24f54e6c69ec97dda83
webhook_reschedule_uid: a9d985437e07f5f7a9cfe8389781a932
webhook_cancellation_reason: b9eb5a356b3a6220ca4d67e0f094555c
webhook_rejection_reason: 0739e8cc7f0707b7a2311a2a79c9f234
webhook_organizer_name: d3f3c549caf1cf9a49e08f764c8bfefa
webhook_organizer_email: 59df32359598e24c3ad299005b4d70b9
webhook_organizer_timezone: b239e17bc05498a8853715f2b714dbba
webhook_organizer_locale: bec80c8ae6d494b9a0f02d156897b60e
webhook_attendee_name: cf1b24a9065f0bddce45c1b9d73bbed4
webhook_attendee_email: 84ba9a09117d2ee1b6ccb1c91dde5598
webhook_attendee_timezone: 28b44fac88a7af5de712b4170d4e4920
webhook_attendee_locale: dd37c0674bd20bf0bddc2d951c567877
webhook_team_name: 8bb3366631903cb55a91cc7bd0e0c62c
webhook_team_members: 114f7a3657f4a5701f6f5c95854633e7
webhook_video_call_url: 624f25ce42822b089ef424e2a329815d
webhook_hide_variables: 01a7943b47f6edbf804e686be36844ad
webhook_show_variable: b710d811d0129eb12bee7f9b9f5c3a4a
webhook_event_and_booking: 8e764304adc4cc1255613aec68921ec4
webhook_people: 3252cfea4b28953da45166db271641f8
webhook_teams: b63448c05270497973ac4407047dae02
webhook_metadata: 695d4f7da261ba76e3be4de495491028
stats: 5cf8b052c89fa1b05397a956ebec69b2
booking_status: fe88452fca9d95db1fce15446f545aab
location_custom_label_input_label: 944fd3bbb28cf884e36ba96ed2186c1c
meeting_link: 892205c2e48aad28d01f62b8ef621560
my_bookings: 2c5d89030d06458a78418ecf8cb8b372
phone: b9537ee90fc5b0116942e0af29d926cc
ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS: 18323f2f3fabb169a7cb50ff62433850
+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")}`));
+2 -2
View File
@@ -1,9 +1,9 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
@@ -42,7 +42,7 @@ export default function AppCard({
hideSettingsIcon?: boolean;
hideAppCardOptions?: boolean;
}) {
const { t } = useTranslation();
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const { setAppData, LockedIcon, disabled: managedDisabled } = useAppContextWithSchema();
const isPlatform = useIsPlatform();
+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);
}
@@ -27,12 +27,6 @@ export const stripeCredentialKeysSchema = z.object({
stripe_publishable_key: z.string(),
});
const stripeAppKeysSchema = z.object({
client_id: z.string(),
payment_fee_fixed: z.number(),
payment_fee_percentage: z.number(),
});
export class PaymentService implements IAbstractPaymentService {
private stripe: Stripe;
private credentials: z.infer<typeof stripeCredentialKeysSchema> | null;
@@ -242,11 +236,6 @@ export class PaymentService implements IAbstractPaymentService {
const setupIntent = paymentObject.setupIntent;
// Parse keys with zod
const { payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(stripeAppKeys?.keys);
const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed);
// Ensure that the stripe customer & payment method still exists
const customer = await this.stripe.customers.retrieve(setupIntent.customer as string, {
stripeAccount: this.credentials.stripe_user_id,
@@ -266,7 +255,6 @@ export class PaymentService implements IAbstractPaymentService {
const params: Stripe.PaymentIntentCreateParams = {
amount: payment.amount,
currency: payment.currency,
application_fee_amount: paymentFee,
customer: setupIntent.customer as string,
payment_method: setupIntent.payment_method as string,
off_session: true,
@@ -370,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) {

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