Compare commits

...
Author SHA1 Message Date
github-actions[bot] 55df0e3c9d chore: version packages (#23927)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-22 14:41:51 +00:00
sean-brydonandMorgan 730aab3e3d fix: always check team.parentID even if no membership (#23976)
* fix: always check team.parentID even if no membership

* refactor permission handling in create event type handler

* remove hasMembership

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-09-22 12:19:06 +00:00
Rajiv Sahal 63740c02c7 feat: calendar view atom v1 (#23840)
* fix: refactor

* add `isMonthViewProp` to header

* feat: init v1 for calendar view atom

* test breaking toggle buttons

* fix: make sure week start is always sunday for calendar view atom

* fixup

* fix: remove extra comments

* fix: add calendar view page in examples app

* chore: add changesets

* fix: coderabbit feedback

* fixup
2025-09-22 12:17:33 +00:00
Hariom Balhara c5942bcfcd Fix nan prefetch in platform (#23913) 2025-09-22 12:41:22 +03:00
Calcom BotandLingo.dev c4547c51a2 feat: update translations via @LingoDotDev (#23767)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-20 10:12:41 -03:00
Anik Dhabal Babu cf89046ede fix: few more e2e flakes (#23966) 2025-09-20 17:17:01 +05:30
chauhan_sandAnik Dhabal Babu d5d44fb64b fix: remove platform client ID from attendee list when adding team members as attendee in a team event (#23866)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-20 09:28:27 +00:00
David Johnson 4bb092c345 docs: Updated getting started process (#23920)
* Updated gettintg started process

* updated the common errors and added an advanced section to link to the docker repo
2025-09-20 09:26:18 +00:00
Morgan e1f7e94e36 fix: org admins can manage team even-types (#23953)
* fix: org admins can manage team even-types

* fix: org admins can manage team even-types
2025-09-19 21:16:26 +01:00
AnshumancanrockandDevanshu Sharma 2e9d5f7df1 fix: loading spinner appears in top-left corner during Round Robin setup (#23823)
* fix

* fix

* fix

* fix

* fix

* fixed

* fix

* Update globals.css

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-09-19 19:30:03 +00:00
Morgan 8c943e8817 chore: documentation of event-type bookingRequiresAuthentication (#23942)
* chore: incomplete documentation of event-type bookingRequiresAuthentication setting

* fixup! chore: incomplete documentation of event-type bookingRequiresAuthentication setting
2025-09-19 18:06:32 +00:00
Morgan 56821553fe chore: do not log whole error stack in getConnectedCalendars (#23951) 2025-09-19 17:57:52 +00:00
Amit SharmaandAnik Dhabal Babu b512ceb217 fix: sender name/id missing in workflows (#23950)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-19 17:32:56 +00:00
cfeb18de3f feat: Billing page redesign plus credits (#23908)
* manage billing section

* wip billing credits

* WIP

* WIP

* Download expense log

* credit worth

* skeleton fixes

* add org tip

* add teams tip

* restore service

* type check

* type check

* fix types

* additional credits

* fix progress bar

* add dashed prop

* match new designs

* hide area with no monthly credits

* fix i18n

* show current balance label

* Update apps/web/modules/settings/billing/billing-view.tsx

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

* spacing + monthly credits not showing additional

* Remove additional credits from monthly calculations

* feat: replace add members redirect with invite modal in billing settings

- Replace Button href with onClick handler to open MemberInvitationModal
- Add MemberInvitationModalWithoutMembers import and state management
- Maintain existing team/org context support
- Follow established modal usage patterns from other components
- Fix lint error by using undefined instead of empty arrow function

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

* Remove redudant vars from method

* fix type check

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-19 16:18:42 +00:00
Eunjae LeeandDevin AI b407be933d feat: add date and time columns to insights CSV exports (#23771)
* feat: add ISO timestamp columns to booking CSV exports

- Add createdAt_iso, startTime_iso, endTime_iso columns to getCsvData() method
- Use .toISOString() format for new columns: 2025-09-09T13:03:55+02:00
- Keep existing timestamp columns unchanged to avoid breaking changes
- Addresses user request for parseable timestamp format in CSV downloads

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

* feat: add ISO timestamp columns to routing CSV exports

- Add ISO format columns for createdAt, bookingCreatedAt, bookingStartTime, bookingEndTime
- Preserve original timestamp format to avoid breaking changes
- Complete implementation for both /insights and /insights/routing pages

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

* feat: replace ISO columns with timezone-aware date/time columns

- Replace _iso columns with separate _date and _time columns
- Use user timezone for proper date/time conversion
- Add timezone parameter to CSV export methods
- Maintain backward compatibility with original timestamp format
- Date format: YYYY-MM-DD, Time format: HH:mm:ss

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

* fix: resolve TypeScript type compatibility issues

- Fix WhereForTeamOrAllTeams type compatibility in routing-events.ts
- Restructure conditional object creation to ensure required properties
- Clean up merge conflict remnants from previous rebase

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

* clean up

* fix: use user profile timezone for CSV exports instead of browser timezone

- Modified rawData endpoint to use ctx.user.timeZone instead of input.timeZone
- Removed timeZone field from bookingRepositoryBaseInputSchema
- Updated useInsightsBookingParameters to remove timeZone property
- Fixed RecentNoShowGuestsChart to use useDataTable for timezone access
- Resolves timezone discrepancy where CSV exports showed incorrect time values

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

* Revert "fix: use user profile timezone for CSV exports instead of browser timezone"

This reverts commit 6356657bd0c503f41349c8d1463bb4dd427b1a2c.

* default columns are formatted as iso

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-19 16:16:54 +00:00
Eunjae Lee 4e1920471d fix: remove page permission check for /insights (#23945)
* fix: remove page permission check for /insights

* add e2e test
2025-09-19 16:04:33 +00:00
Eunjae LeeandAlex van Andel 6ee752aed8 feat: add index [startTime, endTime] for BookingDenormalized (#23944)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-19 15:21:23 +00:00
Eunjae LeeandDevin AI a1922efab5 feat: make orgId optional for user and team scopes in /insights and /insights/routing (#23912)
* feat: make orgId optional for user and team scopes in InsightsRoutingBaseService

- User scope authorization only checks formUserId and formTeamId IS NULL
- Team scope now supports standalone teams without organizations
- Add validation logic to return NOTHING_CONDITION if team belongs to org but no orgId provided
- Add comprehensive test coverage for null/undefined orgId scenarios in both scopes
- Aligns schema with actual usage patterns and supports teams without organizations

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

* feat: extend orgId optional support to InsightsBookingBaseService

- Make orgId optional for user and team scopes in InsightsBookingBaseService
- Update InsightsBookingServicePublicOptions type to allow orgId: number | null
- Add validation logic for team scope to handle missing orgId
- Add comprehensive test coverage for null/undefined orgId scenarios
- Fix type casting issues in test file
- Maintains backward compatibility while supporting teams without organizations

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

* fix: correct authorization logic for optional orgId in team scope

- Skip isOwnerOrAdmin check for team scope when orgId is null (standalone teams)
- Maintain security for org scope and team scope with orgId
- Fixes integration test failures for null orgId test cases

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

* fix: use != null instead of !== undefined for orgId checks

- Properly handle both null and undefined orgId values in authorization logic
- Fix integration test failures where null orgId was incorrectly triggering isOwnerOrAdmin check
- Ensure team scope with null orgId skips ownership validation for standalone teams

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

* fix: always validate team membership for team scope

- Remove orgId condition from isOwnerOrAdmin check for team scope
- Ensure both standalone teams and org-based teams require ownership validation
- Maintain orgId validation logic in buildTeamAuthorizationCondition methods

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

* fix: use nullish() for orgId schema validation

- Change from .optional() to .nullish() to allow both null and undefined
- Fixes schema validation when tests pass orgId: null
- Resolves authorization logic returning NOTHING_CONDITION for valid cases

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

* feat: replace isOwnerOrAdmin with PBAC checkPermission in insights services

- Replace isOwnerOrAdmin method in InsightsBookingBaseService with checkPermission from PermissionCheckService
- Replace isOwnerOrAdmin method in InsightsRoutingBaseService with checkPermission from PermissionCheckService
- Use permission 'insights.read' with fallback roles MembershipRole.OWNER and MembershipRole.ADMIN
- Maintain same method signature and behavior while leveraging PBAC system

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

* clean up types

* add comment

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-19 14:13:21 +00:00
Benny Joo 76332a759b refactor: circular deps between app store and lib [5] (#23936)
* getBulkEventTypes

* 2 eventtypes related utils to features

* locationsResolver

* checkForEmptyAssignment

* mv defaultEvents to features

* update imports

* PrismaAppRepository

* mv currencyConversions from appstore to lib

* useAppsData

* videoClient

* analytics files

* fix

* mv

* prettier

* use named import
2025-09-19 10:16:56 -03:00
emrysal dfdda8a5b8 chore: release v5.7.2
Post release / release (push) Canceled after 0s
2025-09-19 11:45:35 +00:00
Amit Sharma 286ee6b1bd fix: disable intercom while being impersonated (#23935) 2025-09-19 10:32:24 +00:00
Benny Joo ef8b0fa41c chore: eslint rule for blocking importing features from appstore, lib, prisma (#23832)
* eslint rule

* improve

* fix

* improve msg
2025-09-19 08:05:53 +00:00
7fbeeae25b fix: event type not found 400 and other (#23904)
* fix: various fixes specifically to event-types

* Revamp error handling a little; highly flawed

* fix: Test cases that depended on defaultResponder behaviour

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-09-18 18:56:00 +00:00
Anik Dhabal Babu 5cf0553199 fix: fix few e2e flakes (#23926) 2025-09-18 14:57:15 -03:00
Lauris Skraucis 4f114ef8d3 fix: EventTypeSettings Checkbox booking field label (#23891) 2025-09-18 17:14:06 +00:00
Anik Dhabal BabuandDevin AI a9d6fbd14d fix: resolve blank form in analytics app installation step 3 (#23924)
- Add mounted state to ensure component renders after hydration
- Fix hydration mismatch between server and client rendering
- Ensure ConfigureStepCard renders properly on initial load in production

Fixes the issue where GTM analytics app configuration form appears blank
on step 3 until page refresh.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-18 17:11:02 +00:00
Eunjae Lee 3e1629add9 chore: compare values in DateTargetSelector (#23916) 2025-09-18 17:10:14 +00:00
f1ac0642d1 fix: correct route pattern and assertions in duplicate API calls E2E test (#23287)
- Fix route pattern from **/api/trpc/viewer.slots.getSchedule** to **/api/trpc/slots/getSchedule**
- Remove permissive assertions that allowed tests to pass with 0 API calls
- Tests now properly fail when duplicate getSchedule calls are detected
- Addresses issue where E2E tests weren't catching duplicate API calls on main branch

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-09-18 17:09:45 +00:00
4689775a1f chore: Skip static file copy if not required (#23903)
* chore: Skip static file copy if not required

* feat: Replace MD5 hash checks with Turborepo caching for static file copying

- Add copy-app-store-static Turborepo task with proper inputs/outputs
- Remove manual MD5 hash calculation logic that was causing performance issues
- Update package.json scripts to use Turborepo task
- Addresses Keith's feedback about using Turborepo cache instead of manual checks

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Add copy-app-store-static script to package.json for Turborepo task execution

- Add missing script definition in apps/web/package.json
- Enables Turborepo to properly execute the static file copying task
- Fixes 'No tasks were executed' issue by providing the command to run

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Remove skipped jobs from required check failure condition

- Allow conditional E2E jobs to be skipped without failing the required check
- Maintain failure detection for actual job failures and cancellations
- Apply fix consistently to both pr.yml and all-checks.yml workflows

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Revert "fix: Remove skipped jobs from required check failure condition"

This reverts commit 429f0506d009eaae7fa88be6db077681d5124f2a.

* Only write new logs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-09-18 16:44:06 +00:00
Benny Joo 4011733271 refactor: remove @calcom/features dependency from @calcom/prisma (#23820) 2025-09-18 16:40:39 +00:00
Keith Williams f41aa234d7 test: Skip broken atom E2E test (#23925) 2025-09-18 13:11:36 -03:00
a44a3e5b84 feat: add Webhook resource to PBAC system with permission enforcement (#23614)
* feat: add Webhook resource to PBAC system with permission enforcement

- Add Webhook resource to PBAC permission registry with CRUD actions
- Implement PBAC permission checks in webhook handlers (create, edit, delete)
- Add webhook permission translations to common.json
- Use PermissionCheckService with fallback roles [ADMIN, OWNER] for team webhooks
- Maintain backward compatibility when PBAC is disabled
- Follow same pattern as workflow PBAC implementation from PR #22845

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

* fix: implement PBAC permission filtering in webhook list handler

- Add PermissionCheckService to filter team webhooks by webhook.read permission
- Only show webhooks from teams where user has proper permissions
- Maintain backward compatibility with fallback to all team memberships

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

* add migration for default roles

* new forUserMethod

* update webhook repository

* fix UI showing/hiding webhooks for webhoo.create teams

* WIP pbac procedure migratoin + tests

* add more roles to get fallback

* permissions in cmponents instead of readOnly

* passPermissions to list item

* push instant events logic

* Git merge

* wip teamId accessable refactor

* fix delete handler

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-18 07:37:45 -03:00
Hariom Balhara 98a075d062 handle NaN != NaN prefetch (#23910) 2025-09-18 11:34:03 +01:00
Eunjae LeeandDevin AI bdc3cb9d6e feat: allow to choose dateTarget for /insights (startTime by default) (#23752)
* feat: allow to choose dateTarget for /insights (startTime by default)

* feat: add timestamp selector for insights date filtering

- Add TimestampFilter component with Start Time/Created At options
- Extend useInsightsBookingParameters hook with timestamp selection
- Update all insight components to use dateTarget parameter
- Add i18n translations for new UI strings
- Position selector next to DateRangeFilter as requested

Addresses user request to add select box next to date range filter
allowing users to choose between startTime (default) and createdAt
for displaying booking metrics on the Insights page.

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

* refactor: rename TimestampFilter to DateTargetSelector with nuqs URL state

- Rename TimestampFilter component to DateTargetSelector
- Implement nuqs hook in InsightsPageContent for URL state management
- Update useInsightsBookingParameters to return dateTarget from URL state
- Add dateTarget field to insightsRoutingServiceInputSchema and related types
- Simplify individual insight components to use insightsBookingParams directly
- Remove manual timestampTarget destructuring from all components
- Update all tRPC routing service calls to include dateTarget parameter
- All TypeScript checks now pass successfully

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

* update styles

* fix inconsistency

* feat: replace Select with Command component and rename filter ID

- Replace Select with Command + Popover for compact width and wider dropdown
- Add descriptive option labels with translations
- Change filter ID from 'createdAt' to 'timestamp' across all components
- Maintain URL state management with nuqs
- Fix ESLint warning for missing dependency in useEffect

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

* fix: revert routing components to use createdAt filter ID

- Keep timestamp filter ID change scoped only to main insights page
- Routing components should continue using createdAt as filter ID
- Only insights-view.tsx and related booking hooks use timestamp

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

* update styles

* fix trpc router

* refactor timestamp column for insights booking service

* fix

* update text

* rename and clean up

* fix endDate in DateRangeFilter

* fix type errors

* fix startTime filter and type errors

* provide default date range

* add completed to getMembersStatsWithCount

* add unit tests

* fix type error

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-18 11:32:50 +01:00
Rajiv SahalandAlex van Andel 616e4c4908 chore: add stale time for billing check (#23907)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-18 09:47:08 +00:00
emrysal eaf695824b chore: release v5.7.1
Post release / release (push) Canceled after 0s
2025-09-18 09:20:19 +00:00
sean-brydon 69a76f7fb7 fix: reassign PBAC role before deleting (#23889) 2025-09-18 08:30:56 +00:00
Anik Dhabal Babu d70fa46260 fix: event startTime is in utc (#23905) 2025-09-18 05:15:32 +00:00
Anik Dhabal Babu efba58a36a fix: users still get updated email when reassign event after disabling standard emails (#23906)
* fix: users still get updated email

* Update yarn.lock

* update
2025-09-18 05:13:04 +00:00
Benny Joo b3a28dca25 refactor: circular deps between app store and lib [4] (#23829)
* mv team queries to features from lib

* update imports

* same

* wip

* move event manager test

* calendar manager to features from lib

* update imports

* update imports

* fix

* mv getEventTypeById

* fix

* fix

* remove

* fix type check error

* fix tests

* fix tests

* fix
2025-09-18 01:42:40 +01:00
Anik Dhabal Babu 3b471f0e25 fix: one e2e flake visible in ci (#23897)
* fix: e2e flake visible in ci

* fix
2025-09-17 21:25:53 +05:30
Anwar SadathandAnik Dhabal Babu aac5fa3537 fix: Showing 0 in onboard members to team screen (#23882)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-17 15:19:59 +00:00
Anwar SadathandAnik Dhabal Babu 134f7e591e fix: Bookings unconfirmed count not getting updated in left sidebar badge (#23886)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-17 15:14:39 +00:00
5bb2bc3ba4 fix: resolve JSON parsing error in Stripe subscription endpoint (#23892)
* fix: resolve JSON parsing error in Stripe subscription endpoint

- Fix 'Unexpected end of JSON input' error during premium username upgrade
- Replace res.status(404).end() with proper JSON response
- Add descriptive error message for missing userId or intentUsername parameters
- Ensure consistent JSON response format across all error paths

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: add proper session handling to Stripe subscription endpoint

- Import getServerSession from @calcom/features/auth/lib/getServerSession
- Use getServerSession({ req }) to properly populate user session
- Fix session validation that was causing 'Unexpected end of JSON input' error
- Ensure subscription endpoint handles sessions consistently with other Cal.com API endpoints

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-17 15:03:16 +00:00
Anik Dhabal Babu bb6f5cca93 fix: Zod error caused by number-type responses (#23804)
* fix: getting zoderror due to number type responses

* update

* fix lint erro
2025-09-17 15:01:21 +00:00
Anik Dhabal BabuandDevin AI 81bd8033a9 fix: resolve hydration mismatch in analytics app installation (#23877)
- Wrap ConfigureStepCard content with NoSSR component
- Prevents blank step 3 configuration page in production
- Fixes hydration mismatch between server and client rendering
- Ensures analytics apps (GTM, Fathom, GA4) installation works properly
- Uses Cal.com's established NoSSR pattern for client-side only rendering

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-17 20:09:28 +05:30
Hariom Balhara c2a44e9319 fix api-v2 tests (#23896) 2025-09-17 14:32:39 +00:00
Hariom BalharaandDevin AI 39917ebfa6 fix: Alternate reschedule behaviour for past bookings - Don't allow new booking (#23736)
* fix: Past booking redirect

* fix: rename ENV_PAST_BOOKING_RESCHEDULE_CHANGE to ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS for clarity

- Renamed environment variable to better reflect that it contains comma-separated team IDs
- Updated all references in constants.ts, implementation file, tests, and turbo.json
- Addresses PR comment feedback about misleading variable name

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-17 12:47:45 +01:00
Anwar SadathandAnik Dhabal Babu 9ee06b410e fix: Teams list not getting refreshed after adding a new team (#23881)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-17 10:09:37 +00:00
76ced2d3b8 feat: event type pbac (#22618)
* fix members page crash with pbac feature flag

* invite member to org backend

* update org permissions

* feat: org profile update settings

* org general page

* remove redudant me call

* privacy page

* add attributes to pbac

* dync + sso on organization permissions

* add tests for resource-permission util

* pass permissions to attributes\

* restore invite members

* update org update and attribute backends

* fix type errors

* fix orgId

* fix types attempt two

* fix types attempt two

* fix type error

* show/hide team event types based on eventType.read permission

* fix dupe string in i18n

* fix tests

* fix team-dsync

* update session to use profile

* use profile metadata to get orgRolew

* fix dsync

* fix typing

* Event type create pbac check

* fix the readonly permission check. WIP

* refactor getUserEventTypes

* fix test

* add generate util for update

* update router to use new generator function for pbac procedures

* update transform utils

* fix members being able to update on UI

* fix allSettled

* fix fallback permissions

* fix permission logic

* fix compare memebrship type error

* fix unit test error

* fix manage test error

* fix nested permissions

* filter based on canView permission in getUserEventGroups

* Update packages/trpc/server/routers/viewer/eventTypes/utils/EventTypeGroupFilter.test.ts

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

* fix tests

* fix tests

* feat: Adding workflow permission checks inside of eventTypes (#23038)

* add permission checks for workflows

* wip getall active workflows

* check eventType.update permission on activiating workflow on event type

* pass permissions to context and use context in client

* remove local parsePermissionString and use registery

* Use new scoped function

* default scope to orgs

* use object values for workflow permissions

* fix type errors

* Fix permission traversal + fix all state caluclation

* fix eventType group to use permissions.canRead

* fix: update failing PBAC unit tests and resolve ESLint warnings

- Fix getUserEventGroups test: MEMBER role should not have update permissions in fallback scenario
- Fix EventTypeGroupFilter test: add missing canRead property to mock permissions map
- Replace 'as any' type assertions with proper 'as unknown as' type assertions to resolve ESLint warnings

Both tests now pass and align with PBAC permission model where MEMBER role has read-only access to event types.

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

* add pbac to _heavy router

* Merge main

* restore versions

* Parallelizable members query

* fix nits

* fix create

* Restore lock file

* Fix import from merge artifact

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-17 10:25:30 +01:00
Rajiv Sahal eaa1ad6578 feat: e2e tests for Connect atoms (#23433)
* e2e tests for apple connect atom

* update globalEnv for turbo.json

* update pages for tests

* update playwright config so we can use env variables inside tests

* update packages

* update variable names

* update playwright config

* fix: import variables from env instead of separate files

* update env variables for atoms e2e yml
2025-09-17 11:20:38 +02:00
Rajiv Sahal f9095d298d update env variables (#23883) 2025-09-17 08:21:39 +00:00
HichemandKartik Saini fd87954048 feat: show refresh and back button is app mode (#23838)
* Change display mode from 'standalone' to 'minimal-ui'

this shows the refresh and back button for easier navigation ux

* Update display settings in site.webmanifest

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-09-17 12:53:35 +05:30
Morgan be249a94de chore: add auth strategy test only x-cal-client-id (#23872) 2025-09-17 09:05:10 +02:00
59582cf8f5 fix: unable to add or edit user from admin page and add tests (#23871)
* fix

* Update UserForm.tsx

* test: add e2e tests for admin user creation and editing

- Add test for successful user creation via /settings/admin/users/add
- Add test for successful user editing via /settings/admin/users/{id}
- Tests verify form submission, API responses, and database changes
- Follow Cal.com e2e testing patterns with admin authentication
- Use Playwright for end-to-end form interaction testing

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Update admin-users.e2e.ts

* Update admin-users.e2e.ts

* update

* tweak

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-16 23:47:56 +05:30
Udit Takkar 5c70060992 feat: voice and language support in cal.ai (#23865)
* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types
2025-09-16 20:15:11 +05:30
Lauris Skraucis ae7f8f1653 feat: v2 show hidden attendees for authenticated requests (#23868)
* refactor: return attendees when fetching all bookings

* refactor: v2 show hidden attendees for authenticated requests

* chore: regenerate docs

* chore: add test
2025-09-16 19:57:42 +05:30
Eunjae LeeandDevin AI 4d0458b0e8 feat: add client-side auth protection to routing forms pages (#23743)
* feat: add client-side auth protection to routing forms pages

- Create RoutingFormsShell component with useRedirectToLoginIfUnauthenticated hook
- Wrap form-edit, route-builder, and incomplete-booking pages with RoutingFormsShell
- Ensures consistent auth behavior during client-side navigation
- Maintains existing server-side auth protection
- Fix ESLint warnings: replace 'any' types with 'unknown' types and add eslint-disable for restricted imports

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

* refactor: clean up RoutingFormsShell implementation

- Remove unused isPublic parameter from RoutingFormsShell component
- Keep only essential client-side authentication protection functionality
- Fix remaining ESLint warnings to allow commit to pass

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

* remove unnecessary hook

* use shell at layout

* rename component

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-16 10:39:42 -03:00
55e6682832 feat: Custom Fields OR Add-Ons with a fee associated (#19997)
* feat: Addons to bookings

* Add tests

* Add tests

* Fix price field not shown immediately after enabling payment for an event

* Add support for all currencies in addons and fix type errors

* Address review comments

* Add tests to cover all input types

* Address cubic-dev-ai comments

* Remove openapi.json from git tracking

* Revert changes

* Revert unrelated linter/formatter changes

* Address cubic-dev-ai comments

* Addres cubic-dev-ai comment

* Update to use test id instead of hardcoded label

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

* Address coderabbits review comments

* Fix failing unit test

* some fixes...

* Fix tests

* Fix import

* Move handlePayment test file to appropriate location

* Fix test

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-09-16 21:33:23 +09:00
Morgan 65448c017f chore: use local platform libraries instead of npm package (#23834)
* chore: use local platform libraries instead of npm package

* chore: use local platform libraries instead of npm package

* chore: use local platform libraries instead of npm package

* chore: use local platform libraries instead of npm package

* chore: use local platform libraries instead of npm package
2025-09-16 15:00:51 +03:00
Anik Dhabal Babu e9fd23d0d3 fix: unable to add users from the admin page (#23869) 2025-09-16 10:59:21 +00:00
Hariom Balhara badc857bcd Move modules to moduleLoader format (#23740) 2025-09-16 10:58:23 +00:00
Eunjae Lee 7fe80dd3dd Revert "feat: implement generalized navigation permission system with PBAC (#23706)" (#23867)
This reverts commit 34b62f7516.
2025-09-16 11:17:08 +01:00
Udit Takkar 5feca13cfb fix: phone number cancel billing (#23854)
* fix: phone number billing bug

* fix: phone number billing bug

* fix: use raw code

* fix: use zod

* chore: update test

* fix: double cancel

* fix: double cancel

* fix: double cancel

* fix: chore update

* fix: schema
2025-09-16 15:39:32 +05:30
Hariom BalharaandDevin AI f267cd9cd9 fix: change no-restricted-imports to @typescript-eslint/no-restricted-imports for allowTypeImports support (#23860)
The allowTypeImports option is only supported by @typescript-eslint/no-restricted-imports,
not the standard ESLint no-restricted-imports rule. This fixes the configuration error:
'Configuration for rule no-restricted-imports is invalid: allowTypeImports should NOT have additional properties'

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-16 10:00:53 +00:00
Morgan e8c2d4fa95 chore: add build platform libs in api v2 github actions (#23862) 2025-09-16 09:07:47 +00:00
Morgan 7e415b4542 fix: eslint no-restricted-imports app-store rule (#23845) 2025-09-15 22:40:01 +05:30
emrysal 43f4caaba4 chore: release v5.7.0
Post release / release (push) Canceled after 0s
2025-09-15 16:22:52 +00:00
Udit TakkarandAlex van Andel 0c2ba81269 fix: phone number billing bug (#23851)
* fix: phone number billing bug

* fix: phone number billing bug

* fix: use raw code

* fix: use zod

* chore: update test

* Remove newline.

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-15 17:17:05 +01:00
Morgan 3aca339c95 fix: catch HttpError in getRoutedUrl (#23844)
* fix: catch HttpError in getRoutedUrl

* fixup! fix: catch HttpError in getRoutedUrl
2025-09-15 14:34:43 +00:00
sean-brydonandAlex van Andel 41553f6c01 feat: PBAC remove dogfood for safe checking (#23846)
* Remove dogfood for safe checking

* chore: Remove now redundant test cases

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-15 14:57:11 +01:00
Eunjae Lee 3661860433 docs: add search tools priority (#23843) 2025-09-15 13:57:25 +01:00
Keith Williams f0210d06ea chore: Upgrade prisma-kysely (#23825) 2025-09-15 13:44:31 +01:00
Rajiv Sahal fcfb8cc9a3 fix: update validation for billing check endpoint (#23842) 2025-09-15 11:57:58 +00:00
88391c7942 fix: Handle account user lookup fields for queued form responses (#23811)
* Add constants for queued response id query string

* Ignore eslint - app store imports

* Return `routingFormResponseId` or `queuedFormResponseId` from URL params

* Pass routingForm params to `getTeamMemberEmailForResponseOrContact`

* Handle getting form responses for queued form responses

* Type fix

* add test

* Add tests

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-09-15 11:16:50 +00:00
Eunjae Lee e9c6af562e docs: update knowledge base regarding auth checks in page (#23672) 2025-09-15 09:33:06 +00:00
Lauris Skraucis 18bac4a063 feat: v2 toggle transcription settings (#23836) 2025-09-15 09:13:22 +00:00
0332118724 feat: add e2e tests for insights charts (#23777)
* feat: add e2e tests for insights ChartCard components

- Test that all ChartCard components render with expected titles
- Test graceful handling of TRPC call failures
- Test ChartCard rendering for organization users
- Wait for page load and TRPC calls before checking chart presence
- Use text-based selectors to find ChartCard titles in h2 elements
- Remove page.waitForTimeout() calls to comply with ESLint rules

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

* clean up e2e tests

* fix titles

* still render charts if no data

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-15 11:00:00 +02:00
Eunjae LeeandDevin AI 34b62f7516 feat: implement generalized navigation permission system with PBAC (#23706)
* feat: implement generalized navigation permission system with PBAC

- Create NavigationPermissionsProvider context for server-side permission data
- Add checkNavigationPermissions utility using PermissionCheckService
- Update main navigation layout to check permissions server-side
- Enhance useShouldDisplayNavigationItem for generalized permission filtering
- Support permission mapping for insights, workflows, routing, teams, members
- Use unstable_cache for performance optimization
- Replace insights-specific logic with scalable PBAC-based system

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

* fix: correct flag check logic in useShouldDisplayNavigationItem

- Only return false when flag is explicitly false
- Allow navigation permission checks to run when flags are truthy
- Fixes bug where truthy flags would short-circuit permission checks

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

* fix: resolve linting errors in embed-core files

- Replace forbidden non-null assertion with null check in embed.ts
- Replace 'any' type with 'unknown' in embed.test.ts
- Replace non-null assertion with proper null check in EmbedElement.test.ts
- Remove unused variable declaration

These are pre-existing linting issues unrelated to navigation permissions feature.

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

* fix: resolve server-client boundary issue in NavigationPermissionsProvider

- Add 'use client' directive to NavigationPermissionsProvider.ts
- Replace JSX syntax with React.createElement to fix TypeScript compilation
- Create NavigationPermissionsWrapper.tsx as client component bridge
- Update layout.tsx to use wrapper instead of provider directly
- Ensure proper separation between server-side permission checking and client-side context consumption

This fixes the architectural issue where useContext was being used in server components.

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

* Revert "fix: resolve linting errors in embed-core files"

This reverts commit 4f481bae576376c9f9177215f7872c8bf7132894.

* refactor: simplify NavigationPermissionsProvider approach

- Remove NavigationPermissionsWrapper.tsx (revert to simpler approach)
- Rename NavigationPermissionsProvider.ts to .tsx for JSX support
- Update layout.tsx to use NavigationPermissionsProvider directly
- Keep 'use client' directive for proper server-client boundary handling
- Maintain all navigation permission functionality with cleaner architecture

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

* clean up

* return true for no team situation

* fix: prevent hidden navigation items from reappearing via stored expansion state

- Modified usePersistedExpansionState hook to accept shouldDisplay parameter
- Clear stored expansion state for items that shouldn't be displayed
- Prevent state persistence for items without permissions
- Updated both NavigationItem and MobileNavigationMoreItem components
- Use safe sessionStorage import from @calcom/lib/webstorage

Fixes bug where clicking 'Workflows' would restore 'Insights' menu visibility
even when user lacks insights permissions due to sessionStorage state restoration.

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

* fix: add sessionStorage to webstorage module and export NavigationItemName type

- Added sessionStorage wrapper to @calcom/lib/webstorage with safe error handling
- Export NavigationItemName type from NavigationPermissionsProvider for proper type access
- Resolves TypeScript compilation errors in navigation permission system

These changes enable the collapsible menu state bug fix to compile properly.

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

* refactor: improve separation of concerns in usePersistedExpansionState hook

- Remove shouldDisplay parameter from usePersistedExpansionState hook
- Move state clearing logic for hidden items to NavigationItem component level
- Add setIsExpanded to useEffect dependency arrays to fix ESLint warnings
- Maintain bug fix functionality while improving code design
- Better separation between expansion state management and display permissions

This addresses user feedback about inappropriate coupling between concerns.

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

* fix provider

* fix unstable_cache

* refactor: move navigation permission checks to client-side with tRPC

- Replace server-side unstable_cache permission checks with client-side tRPC query
- Add getNavigationPermissions query to PBAC router
- Update NavigationPermissionsProvider to use tRPC with loading states
- Remove server-side checkNavigationPermissions function
- Improve initial page load performance by deferring permission checks

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

* refactor: centralize navigation item definitions to eliminate repetition

- Create NAVIGATION_ITEMS_CONFIG as single source of truth for navigation items
- Auto-generate NAVIGATION_PERMISSION_MAP from centralized config
- Update Navigation.tsx to use centralized definitions for teams, routing, workflows, insights
- Fix import issues in NavigationPermissionsProvider.tsx
- Eliminate hardcoded repetition of navigation item properties across codebase

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

* clean up

* clean up permissions

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-15 10:55:51 +02:00
Eunjae LeeandDevin AI da1863069d feat: sort bookingUserId options alphabetically on the /insights/routing page (#23791)
* feat: sort bookingUserId options alphabetically in insights routing faceted values

- Add .sort((a, b) => a.label.localeCompare(b.label)) to bookingUserId options
- Makes user dropdown more human-friendly with alphabetical ordering
- Follows existing sorting pattern used in FilterSegmentSelect.tsx
- Fix ESLint warning by replacing Table<any> with Table<unknown>

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

* fix: use generic type parameter instead of unknown in Table type

- Replace Table<unknown> with <TData>(_: Table<TData>) to fix CI type errors
- Maintains type safety while avoiding ESLint any type warning
- Allows hook to work with any table type through generic parameter

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-15 09:53:30 +01:00
Joe Au-YeungandHariom Balhara 31270ca86b feat: Write same metadata to Stripe for booking payment and no show fee payment (#23634)
* Add `findBookingById` to `BookingRepository`

* Create `generateMetadata` to Stripe `PaymentService`

* `create` method to use `generateMetadata`

* Remove unused trpc endpoint

* Pass booking id to chargeCard method

* Use generateMetadata in chargeCard method

* Remove unused stripeAppKeys

* Add error handling if booking doesn't contain attendee

* Rename  to

* Return attendees in a consistent order

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-09-15 14:20:05 +05:30
Morgan 7e876e3457 fix: use class CreateDelegationCredentialInput instead of type for api v2 (#23831) 2025-09-15 07:59:30 +00:00
Peer Richelsen cbfd991ab7 chore: small cal.ai updates (#23827)
* Update page.tsx

* fixed close button

* typo

* fixed i18n cal.ai
2025-09-14 20:21:56 +01:00
455 changed files with 15848 additions and 4612 deletions
+49
View File
@@ -319,6 +319,55 @@ dates.map((date) => dayjs.utc(date).add(1, "day").format());
dates.map((date) => new Date(date.valueOf() + 24 * 60 * 60 * 1000));
```
## Next.js App Directory: Authorisation Checks in Pages
This can include checking session.user exists or session.org etc.
### TL;DR:
Don’t put permission checks in layout.tsx! Always put them directly inside your page.tsx or relevant server components for every restricted route.
### Why Not to Use Layouts for Permission Checks
* Layouts don’t intercept all requests: If a user navigates directly or refreshes a protected route, layout checks might be skipped, exposing sensitive content.
* APIs and server actions bypass layouts: Sensitive operations running on the server can’t be guarded by checks in the layout.
* Risk of data leaks: Only page/server-level checks ensure that unauthorized users never get protected data.
### ✅ How To Secure Routes (The Right Way)
* Check permissions inside page.tsx or the actual server component.
* Perform all session/user/role validation before querying or rendering sensitive content.
* Redirect or return nothing to unauthorized users, before running restricted code.
### 🛠️ Example: Page-Level Permission Check
```tsx
// app/admin/page.tsx
import { redirect } from "next/navigation";
import { getUserSession } from "@/lib/auth";
export default async function AdminPage() {
const session = await getUserSession();
if (!session || session.user.role !== "admin") {
redirect("/"); // Or show an error
}
// Protected content here
return <div>Welcome, Admin!</div>;
}
```
### 🧠 Key Reminders
* Put permission guards in every restricted page.tsx.
* Never assume layouts are secure for guarding data.
* Validate users before any sensitive queries or rendering.
## Avoid using Dayjs if you don’t need to be strictly tz aware.
When doing logic like Dayjs.startOf(".."), you can instead use date-fns' `startOfMonth(dateObj)` / `endOfDay(dateObj)`;
+1 -1
View File
@@ -7,7 +7,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@calcom/platform-libraries"],
"ignore": ["@calcom/platform-libraries", "@calcom/api-v2"],
"privatePackages": {
"version": false,
"tag": false
+82 -7
View File
@@ -1,4 +1,3 @@
// This configuration only applies to the package manager root.
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
@@ -7,29 +6,105 @@ module.exports = {
"import/no-cycle": ["warn", { maxDepth: Infinity }],
},
overrides: [
// WARN: features must not be imported by app-store or lib
{
files: ["packages/lib/**/*.{ts,tsx,js,jsx}", "packages/prisma/**/*.{ts,tsx,js,jsx}"],
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
paths: ["@calcom/app-store"],
patterns: ["@calcom/app-store/*"],
patterns: [
{
group: [
// Catch all relative paths into features
"**/features",
"**/features/*",
// Catch all alias imports
"@calcom/features",
"@calcom/features/*",
],
message: "Avoid importing @calcom/features from @calcom/app-store or @calcom/lib.",
},
],
},
],
},
},
// WARN: lib must not import app-store or features
{
files: ["packages/lib/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
patterns: [
{
group: [
// Catch all relative paths into app-store
"**/app-store",
"**/app-store/*",
// Catch all relative paths into features
"**/features",
"**/features/*",
// Catch alias imports
"@calcom/app-store",
"@calcom/app-store/*",
"@calcom/features",
"@calcom/features/*",
],
message: "@calcom/lib should not import @calcom/app-store or @calcom/features.",
},
],
},
],
},
},
// ERROR: app-store must not import trpc
{
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"error",
{
allowTypeImports: false,
patterns: [
{
group: ["@calcom/trpc/*", "@trpc/*"],
message: "tRPC imports are blocked in packages/app-store. Move UI to apps/web/components/apps or introduce an API boundary.",
group: [
// Catch all relative paths into trpc
"**/trpc",
"**/trpc/*",
// Catch alias imports
"@calcom/trpc",
"@calcom/trpc/*",
"@trpc",
"@trpc/*",
],
message:
"@calcom/app-store must not import trpc. Move UI to apps/web/components/apps or introduce an API boundary.",
},
],
},
],
},
},
// ERROR: prisma must not import `features` package
{
files: ["packages/prisma/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
// Catch all relative paths into features
"**/features",
"**/features/*",
// Catch all alias imports
"@calcom/features",
"@calcom/features/*",
],
message: "Avoid importing @calcom/features from @calcom/prisma.",
},
],
},
+2 -2
View File
@@ -71,9 +71,9 @@ jobs:
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e
yarn workspace @calcom/platform-libraries build && yarn test:e2e
EXIT_CODE=$?
echo "yarn test:e2e command exit code: $EXIT_CODE"
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
exit $EXIT_CODE
- name: Upload Test Results
if: ${{ always() }}
+2
View File
@@ -13,6 +13,8 @@ env:
ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
ATOMS_E2E_APPLE_ID: ${{ secrets.ATOMS_E2E_APPLE_ID }}
ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE: ${{ secrets.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+1 -1
View File
@@ -21,4 +21,4 @@ jobs:
working-directory: apps/api/v2
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn test
yarn workspace @calcom/platform-libraries build && yarn test
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+10
View File
@@ -13,6 +13,16 @@
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
## Tool Preferences
### Search Tools Priority
Use tools in this order of preference:
1. **ast-grep** - For AST-based code searches (if available)
2. **rg (ripgrep)** - For fast text searches
3. **grep** - As fallback for text searches
## 📚 Detailed Documentation
- **[.agents/README.md](.agents/README.md)** - Complete development guide
+2 -8
View File
@@ -1,20 +1,14 @@
import { captureException as SentryCaptureException } from "@sentry/nextjs";
import type { NextMiddleware } from "next-api-middleware";
import { redactError } from "@calcom/lib/redactError";
export const captureErrors: NextMiddleware = async (_req, res, next) => {
try {
// Catch any errors that are thrown in remaining
// middleware and the API route handler
await next();
} catch (error) {
console.error(error);
SentryCaptureException(error);
const redactedError = redactError(error);
if (redactedError instanceof Error) {
res.status(400).json({ message: redactedError.message, error: redactedError });
return;
}
res.status(400).json({ message: "Something went wrong", error });
res.status(500).json({ message: "Something went wrong" });
}
};
@@ -30,7 +30,7 @@ describe("rateLimitApiKey middleware", () => {
query: {},
userId: testUserId,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(res._getStatusCode()).toBe(401);
@@ -43,7 +43,7 @@ describe("rateLimitApiKey middleware", () => {
query: { apiKey: "test-key" },
userId: testUserId,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(checkRateLimitAndThrowError as any).mockResolvedValueOnce({
limit: 100,
remaining: 99,
@@ -51,6 +51,7 @@ describe("rateLimitApiKey middleware", () => {
});
// @ts-expect-error weird typing between middleware and createMocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
@@ -74,15 +75,14 @@ describe("rateLimitApiKey middleware", () => {
success: true,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);
// @ts-expect-error weird typing between middleware and createMocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
@@ -96,10 +96,11 @@ describe("rateLimitApiKey middleware", () => {
query: { apiKey: "test-key" },
userId: testUserId,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded"));
// @ts-expect-error weird typing between middleware and createMocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(res._getStatusCode()).toBe(429);
@@ -121,6 +122,7 @@ describe("rateLimitApiKey middleware", () => {
};
// Mock rate limiter to trigger the onRateLimiterResponse callback
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
@@ -131,6 +133,7 @@ describe("rateLimitApiKey middleware", () => {
vi.mocked(handleAutoLock).mockResolvedValueOnce(true);
// @ts-expect-error weird typing between middleware and createMocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(handleAutoLock).toHaveBeenCalledWith({
@@ -158,6 +161,7 @@ describe("rateLimitApiKey middleware", () => {
};
// Mock rate limiter to trigger the onRateLimiterResponse callback
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
@@ -168,6 +172,7 @@ describe("rateLimitApiKey middleware", () => {
vi.mocked(handleAutoLock).mockRejectedValueOnce(new Error("No user found for this API key."));
// @ts-expect-error weird typing between middleware and createMocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(handleAutoLock).toHaveBeenCalledWith({
@@ -195,6 +200,7 @@ describe("rateLimitApiKey middleware", () => {
};
// Mock rate limiter to trigger the onRateLimiterResponse callback
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
@@ -235,6 +241,7 @@ describe("rateLimitApiKey middleware", () => {
);
// @ts-expect-error weird typing between middleware and createMocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await rateLimitApiKey(req, res, vi.fn() as any);
expect(res._getStatusCode()).toBe(429);
+68
View File
@@ -0,0 +1,68 @@
import type { Prisma } from "@calcom/prisma/client";
export const eventTypeSelect = {
id: true,
title: true,
slug: true,
length: true,
hidden: true,
position: true,
userId: true,
teamId: true,
scheduleId: true,
eventName: true,
timeZone: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
recurringEvent: true,
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
schedulingType: true,
price: true,
currency: true,
slotInterval: true,
parentId: true,
successRedirectUrl: true,
description: true,
locations: true,
metadata: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
bookingFields: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
customInputs: {
select: { id: true, label: true, required: true, options: true, type: true, placeholder: true },
},
hashedLink: {
select: { link: true },
},
team: {
select: { slug: true },
},
hosts: {
select: {
userId: true,
isFixed: true,
scheduleId: true,
},
},
owner: {
select: { username: true, id: true },
},
children: {
select: {
id: true,
userId: true,
},
},
} satisfies Prisma.EventTypeSelect;
+1 -66
View File
@@ -6,13 +6,12 @@ import {
MIN_EVENT_DURATION_MINUTES,
} from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { EventTypeSchema } from "@calcom/prisma/zod/modelSchema/EventTypeSchema";
import { HostSchema } from "@calcom/prisma/zod/modelSchema/HostSchema";
import { Frequency } from "~/lib/types";
import { jsonSchema } from "./shared/jsonSchema";
import { schemaQueryUserId } from "./shared/queryUserId";
import { timeZone } from "./shared/timeZone";
@@ -118,67 +117,3 @@ const schemaEventTypeEditParams = z
.strict();
export const schemaEventTypeEditBodyParams = schemaEventTypeBaseBodyParams.merge(schemaEventTypeEditParams);
export const schemaEventTypeReadPublic = EventTypeSchema.pick({
id: true,
title: true,
slug: true,
length: true,
hidden: true,
position: true,
userId: true,
teamId: true,
scheduleId: true,
eventName: true,
timeZone: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
recurringEvent: true,
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
schedulingType: true,
price: true,
currency: true,
slotInterval: true,
parentId: true,
successRedirectUrl: true,
description: true,
locations: true,
metadata: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
bookingFields: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
}).merge(
z.object({
children: z.array(childrenSchema).optional().default([]),
hosts: z.array(hostSchema).optional().default([]),
locations: z
.array(
z.object({
link: z.string().optional(),
address: z.string().optional(),
hostPhoneNumber: z.string().optional(),
type: z.any().optional(),
})
)
.nullable(),
metadata: jsonSchema.nullable(),
customInputs: customInputSchema.array().optional(),
link: z.string().optional(),
hashedLink: z
.array(z.object({ link: z.string() }))
.optional()
.default([]),
bookingFields: eventTypeBookingFields.optional().nullable(),
})
);
@@ -102,8 +102,11 @@ export async function getHandler(req: NextApiRequest) {
const booking = await prisma.booking.findUnique({
where: { id },
include: {
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
attendees: true,
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
user: true,
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
payment: true,
eventType: expand.includes("team") ? { include: { team: true } } : false,
},
@@ -1,11 +1,11 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/lib/videoClient";
} from "@calcom/app-store/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
import type { PartialReference } from "@calcom/types/EventManager";
@@ -63,6 +63,7 @@ export async function getHandler(req: NextApiRequest) {
const booking = await prisma.booking.findUnique({
where: { id },
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { references: true },
});
@@ -1,11 +1,11 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/lib/videoClient";
} from "@calcom/app-store/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import type { PartialReference } from "@calcom/types/EventManager";
@@ -61,6 +61,7 @@ export async function getHandler(req: NextApiRequest) {
const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { references: true },
});
@@ -1,8 +1,8 @@
import type { NextApiRequest } from "next";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/lib/videoClient";
import prisma from "@calcom/prisma";
import type { PartialReference } from "@calcom/types/EventManager";
@@ -44,6 +44,7 @@ export async function getHandler(req: NextApiRequest) {
const booking = await prisma.booking.findUnique({
where: { id },
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { references: true },
});
@@ -1,7 +1,10 @@
import type { NextApiRequest } from "next";
import type { z } from "zod";
import { getCalendarCredentialsWithoutDelegation, getConnectedCalendars } from "@calcom/lib/CalendarManager";
import {
getCalendarCredentialsWithoutDelegation,
getConnectedCalendars,
} from "@calcom/features/calendars/lib/CalendarManager";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -1,6 +1,9 @@
import type { NextApiRequest } from "next";
import { getCalendarCredentialsWithoutDelegation, getConnectedCalendars } from "@calcom/lib/CalendarManager";
import {
getCalendarCredentialsWithoutDelegation,
getConnectedCalendars,
} from "@calcom/features/calendars/lib/CalendarManager";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -1,8 +1,7 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
@@ -66,4 +65,4 @@ async function checkPermissions(req: NextApiRequest) {
throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default defaultResponder(deleteHandler);
export default deleteHandler;
+15 -15
View File
@@ -1,11 +1,10 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
import { checkPermissions as canAccessTeamEventOrThrow } from "~/pages/api/teams/[teamId]/_auth-middleware";
@@ -49,15 +48,13 @@ export async function getHandler(req: NextApiRequest) {
const eventType = await prisma.eventType.findUnique({
where: { id },
include: {
customInputs: true,
hashedLink: { select: { link: true } },
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
select: eventTypeSelect,
});
if (!eventType) {
throw new HttpError({ statusCode: 404, message: "Event type not found" });
}
await checkPermissions(req, eventType);
const link = eventType ? getCalLink(eventType) : null;
@@ -74,9 +71,12 @@ export async function getHandler(req: NextApiRequest) {
eventType.scheduleId = user.defaultScheduleId;
}
// TODO: eventType when not found should be a 404
// but API consumers may depend on the {} behaviour.
return { event_type: schemaEventTypeReadPublic.parse({ ...eventType, link }) };
return {
event_type: {
...eventType,
link,
},
};
}
type BaseEventTypeCheckPermissions = {
@@ -100,4 +100,4 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default defaultResponder(getHandler);
export default getHandler;
@@ -2,13 +2,13 @@ import type { NextApiRequest } from "next";
import type { z } from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { eventTypeSelect } from "~/lib/selects/event-type";
import type { schemaEventTypeBaseBodyParams } from "~/lib/validations/event-type";
import { schemaEventTypeEditBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { schemaEventTypeEditBodyParams } from "~/lib/validations/event-type";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
import ensureOnlyMembersAsHosts from "~/pages/api/event-types/_utils/ensureOnlyMembersAsHosts";
@@ -235,8 +235,8 @@ export async function patchHandler(req: NextApiRequest) {
};
}
await checkPermissions(req, parsedBody);
const eventType = await prisma.eventType.update({ where: { id }, data });
return { event_type: schemaEventTypeReadPublic.parse(eventType) };
const eventType = await prisma.eventType.update({ where: { id }, data, select: eventTypeSelect });
return { event_type: eventType };
}
async function checkPermissions(req: NextApiRequest, body: z.infer<typeof schemaEventTypeBaseBodyParams>) {
@@ -249,4 +249,4 @@ async function checkPermissions(req: NextApiRequest, body: z.infer<typeof schema
await checkTeamEventEditPermission(req, body);
}
export default defaultResponder(patchHandler);
export default patchHandler;
+4 -11
View File
@@ -2,10 +2,10 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
import { schemaQuerySlug } from "~/lib/validations/shared/querySlug";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
@@ -54,14 +54,7 @@ async function getHandler(req: NextApiRequest) {
userId: shouldUseUserId ? { in: userIds } : undefined,
slug: slug, // slug will be undefined if not provided in query
},
include: {
customInputs: true,
hashedLink: { select: { link: true } },
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
select: eventTypeSelect,
});
// this really should return [], but backwards compatibility..
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
@@ -69,7 +62,7 @@ async function getHandler(req: NextApiRequest) {
event_types: (await defaultScheduleId<(typeof data)[number]>({ eventTypes: data, prisma, userIds })).map(
(eventType) => {
const link = getCalLink(eventType);
return schemaEventTypeReadPublic.parse({ ...eventType, link });
return { ...eventType, link };
}
),
};
+5 -4
View File
@@ -2,11 +2,12 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
import { schemaEventTypeCreateBodyParams } from "~/lib/validations/event-type";
import { canUserAccessTeamWithRole } from "~/pages/api/teams/[teamId]/_auth-middleware";
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
@@ -306,10 +307,10 @@ async function postHandler(req: NextApiRequest) {
data.hosts = { createMany: { data: hosts } };
}
const eventType = await prisma.eventType.create({ data, include: { hosts: true } });
const eventType = await prisma.eventType.create({ data, select: eventTypeSelect });
return {
event_type: schemaEventTypeReadPublic.parse(eventType),
event_type: eventType,
message: "Event type created successfully",
};
}
+1
View File
@@ -46,6 +46,7 @@ export async function paymentById(
if (safeQuery.success && method === "GET") {
const userWithBookings = await prisma.user.findUnique({
where: { id: userId },
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { bookings: true },
});
await prisma.payment
+5 -1
View File
@@ -75,7 +75,11 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const data = await prisma.schedule.findUniqueOrThrow({ where: { id }, include: { availability: true } });
const data = await prisma.schedule.findUniqueOrThrow({
where: { id },
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { availability: true },
});
return { schedule: schemaSchedulePublic.parse(data) };
}
@@ -62,6 +62,7 @@ export async function patchHandler(req: NextApiRequest) {
/** Only OWNERS and ADMINS can edit teams */
const _team = await prisma.team.findFirst({
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { members: true },
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
});
@@ -2,10 +2,10 @@ import type { NextApiRequest } from "next";
import { z } from "zod";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
const querySchema = z.object({
teamId: z.coerce.number(),
@@ -57,17 +57,11 @@ async function getHandler(req: NextApiRequest) {
members: { some: { userId } },
},
},
include: {
customInputs: true,
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
select: eventTypeSelect,
};
return {
event_types: await prisma.eventType.findMany(args),
};
const data = await prisma.eventType.findMany(args);
return { event_types: data.map((eventType) => schemaEventTypeReadPublic.parse(eventType)) };
}
export default defaultResponder(getHandler);
@@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/lib/videoClient";
} from "@calcom/app-store/videoClient";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
const adminUserId = 1;
const memberUserId = 10;
vi.mock("@calcom/lib/videoClient", () => {
vi.mock("@calcom/app-store/videoClient", () => {
return {
getRecordingsOfCalVideoByRoomName: vi.fn(),
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
@@ -5,11 +5,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import { buildBooking } from "@calcom/lib/test/builder";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/lib/videoClient";
} from "@calcom/app-store/videoClient";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/lib/videoClient", () => {
vi.mock("@calcom/app-store/videoClient", () => {
return {
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
checkIfRoomNameMatchesInRecording: vi.fn(),
@@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/lib/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/lib/videoClient", () => {
vi.mock("@calcom/app-store/videoClient", () => {
return {
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
};
+1 -1
View File
@@ -86,7 +86,7 @@ vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
}),
}));
vi.mock("@calcom/lib/EventManager", () => ({
vi.mock("@calcom/features/bookings/lib/EventManager", () => ({
default: vi.fn().mockImplementation(() => ({
reschedule: vi.fn().mockResolvedValue({
results: [],
@@ -60,7 +60,7 @@ describe("DELETE /api/event-types/[id]", () => {
describe("Error", async () => {
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
@@ -71,15 +71,18 @@ describe("DELETE /api/event-types/[id]", () => {
// Assign userId to the request objects
req.userId = memberUser;
await handler(req, res);
expect(res.statusCode).toBe(403); // Check if the deletion was successful
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 403,
})
);
});
});
describe("Success", async () => {
test("Removes event type if user is owner of team associated with event type", async () => {
// Mocks for DELETE request
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
@@ -90,8 +93,8 @@ describe("DELETE /api/event-types/[id]", () => {
// Assign userId to the request objects
req.userId = adminUser;
await handler(req, res);
expect(res.statusCode).toBe(200); // Check if the deletion was successful
const deletedEventType = await handler(req);
expect(deletedEventType).not.toBeNull();
});
});
});
@@ -16,7 +16,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
describe("GET /api/event-types/[id]", () => {
describe("Errors", () => {
test("Returns 403 if user not admin/team member/event owner", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -32,16 +32,40 @@ describe("GET /api/event-types/[id]", () => {
);
req.userId = 333333;
await handler(req, res);
expect(res.statusCode).toBe(403);
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 403,
})
);
});
test("Returns 404 if event type not found", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: 123456,
},
});
req.isSystemWideAdmin = true;
prismaMock.eventType.findUnique.mockResolvedValue(null);
req.userId = 333333;
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 404,
})
);
});
});
describe("Success", async () => {
test("Returns event type if user is admin", async () => {
const eventTypeId = 123456;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -57,17 +81,17 @@ describe("GET /api/event-types/[id]", () => {
req.isSystemWideAdmin = true;
req.userId = 333333;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(eventTypeId);
});
test("Returns event type if user is in team associated with event type", async () => {
const eventTypeId = 123456;
const teamId = 9999;
const userId = 333333;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -94,10 +118,10 @@ describe("GET /api/event-types/[id]", () => {
req.isSystemWideAdmin = false;
req.userId = userId;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(eventTypeId);
expect(prismaMock.team.findFirst).toHaveBeenCalledWith({
where: {
id: teamId,
@@ -116,7 +140,7 @@ describe("GET /api/event-types/[id]", () => {
test("Returns event type if user is the event type owner", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -134,10 +158,10 @@ describe("GET /api/event-types/[id]", () => {
req.isSystemWideAdmin = false;
req.userId = userId;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(eventTypeId);
expect(prismaMock.team.findFirst).not.toHaveBeenCalled();
});
});
+2
View File
@@ -22,6 +22,8 @@ RUN yarn workspace @calcom/api-v2 run generate-schemas
RUN rm -rf apps/api/v2/node_modules
RUN yarn install
RUN yarn workspace @calcom/platform-libraries run build
RUN yarn workspace @calcom/api-v2 run build
EXPOSE 80
+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.359",
"@calcom/platform-libraries": "*",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -11,7 +11,12 @@ import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/se
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import {
API_KEY_OR_ACCESS_TOKEN_HEADER,
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import {
AuthOptionalUser,
@@ -22,8 +27,8 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
@@ -101,6 +106,9 @@ export class BookingsController_2024_08_13 {
@Post("/")
@UseGuards(OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Create a booking",
description: `
@@ -122,6 +130,9 @@ export class BookingsController_2024_08_13 {
And 2 ways to book and event type belonging to a team:
1. Provide \`eventTypeId\` in the request body.
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
`,
})
@ApiBody({
@@ -161,7 +172,10 @@ export class BookingsController_2024_08_13 {
}
@Get("/:bookingUid")
@UseGuards(BookingUidGuard)
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get a booking",
description: `\`:bookingUid\` can be
@@ -170,10 +184,17 @@ export class BookingsController_2024_08_13 {
2. uid of one of the recurring booking recurrences
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`,
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
`,
})
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.getBooking(bookingUid);
async getBooking(
@Param("bookingUid") bookingUid: string,
@GetOptionalUser() user: AuthOptionalUser
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.getBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
@@ -218,7 +239,7 @@ export class BookingsController_2024_08_13 {
@ApiOperation({ summary: "Get all bookings" })
async getBookings(
@Query() queryParams: GetBookingsInput_2024_08_13,
@GetUser() user: UserWithProfile
@GetUser() user: ApiAuthGuardUser
): Promise<GetBookingsOutput_2024_08_13> {
const profile = this.usersService.getUserMainProfile(user);
@@ -236,7 +257,10 @@ export class BookingsController_2024_08_13 {
}
@Post("/:bookingUid/reschedule")
@UseGuards(BookingUidGuard)
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reschedule a booking",
description: "Reschedule a booking or seated booking",
@@ -248,17 +272,20 @@ export class BookingsController_2024_08_13 {
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
],
},
description:
"Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2)",
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.`,
})
@ApiExtraModels(RescheduleBookingInput_2024_08_13, RescheduleSeatedBookingInput_2024_08_13)
async rescheduleBooking(
@Param("bookingUid") bookingUid: string,
@Body(new RescheduleBookingInputPipe())
body: RescheduleBookingInput,
@Req() request: Request
@Req() request: Request,
@GetOptionalUser() user: AuthOptionalUser
): Promise<RescheduleBookingOutput_2024_08_13> {
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body, user);
await this.bookingsService.billRescheduledBooking(newBooking, bookingUid);
return {
@@ -268,7 +295,10 @@ export class BookingsController_2024_08_13 {
}
@Post("/:bookingUid/cancel")
@UseGuards(BookingUidGuard)
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Cancel a booking",
@@ -281,7 +311,11 @@ export class BookingsController_2024_08_13 {
\nCancelling recurring seated bookings:
For recurring seated bookings it is not possible to cancel all of them with 1 call
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
`,
})
@ApiBody({
schema: {
@@ -298,9 +332,10 @@ export class BookingsController_2024_08_13 {
@Req() request: Request,
@Param("bookingUid") bookingUid: string,
@Body(new CancelBookingInputPipe())
body: CancelBookingInput
body: CancelBookingInput,
@GetOptionalUser() user: AuthOptionalUser
): Promise<CancelBookingOutput_2024_08_13> {
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body, user);
return {
status: SUCCESS_STATUS,
@@ -342,7 +377,7 @@ export class BookingsController_2024_08_13 {
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
@GetUser() user: ApiAuthGuardUser
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
@@ -392,7 +427,7 @@ export class BookingsController_2024_08_13 {
})
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
@GetUser() user: ApiAuthGuardUser
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
@@ -414,7 +449,7 @@ export class BookingsController_2024_08_13 {
async declineBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: DeclineBookingInput_2024_08_13,
@GetUser() user: UserWithProfile
@GetUser() user: ApiAuthGuardUser
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
@@ -437,61 +437,209 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should book an event type with attendees disabled", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
describe("book an event type with attendees disabled but auth provided", () => {
let booking: CreateSeatedBookingOutput_2024_08_13;
it("should book an event type with attendees disabled and no auth provided", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.eventType).toEqual({
id: seatedEventTypeIdAttendeesDisabledId,
slug: seatedEventSlugAttendeesDisabled,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected seated booking but received non array response"
);
}
});
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.eventType).toEqual({
id: seatedEventTypeIdAttendeesDisabledId,
slug: seatedEventSlugAttendeesDisabled,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
booking = data;
} else {
throw new Error(
"Invalid response data - expected seated booking but received non array response"
);
}
});
});
it("should fetch booking and not have attendees because no auth provided", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${booking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(booking.start);
expect(data.end).toEqual(booking.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.eventType).toEqual({
id: seatedEventTypeIdAttendeesDisabledId,
slug: seatedEventSlugAttendeesDisabled,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
await bookingsRepositoryFixture.deleteById(data.id);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
});
describe("book an event type with attendees disabled but auth provided", () => {
let booking: CreateSeatedBookingOutput_2024_08_13;
it("should book an event type with attendees disabled and owner auth provided", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", apiKeyString)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.eventType).toEqual({
id: seatedEventTypeIdAttendeesDisabledId,
slug: seatedEventSlugAttendeesDisabled,
});
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0].email).toEqual(emailAttendeeOne);
expect(data.attendees[0].name).toEqual(nameAttendeeOne);
expect(data.attendees[0].language).toEqual("it");
expect(data.attendees[0].seatUid).toBeDefined();
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
booking = data;
} else {
throw new Error(
"Invalid response data - expected seated booking but received non array response"
);
}
});
});
it("should fetch booking and have attendees", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${booking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", apiKeyString)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(booking.start);
expect(data.end).toEqual(booking.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.eventType).toEqual({
id: seatedEventTypeIdAttendeesDisabledId,
slug: seatedEventSlugAttendeesDisabled,
});
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0].email).toEqual(emailAttendeeOne);
expect(data.attendees[0].name).toEqual(nameAttendeeOne);
expect(data.attendees[0].language).toEqual("it");
expect(data.attendees[0].seatUid).toBeDefined();
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
await bookingsRepositoryFixture.deleteById(data.id);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
});
describe("cancel seated booking", () => {
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { getPagination } from "@/lib/pagination/pagination";
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
@@ -120,7 +121,10 @@ export class BookingsService_2024_08_13 {
if (!eventType) {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser);
const userIsEventTypeAdminOrOwner = authUser
? await this.userIsEventTypeAdminOrOwner(authUser, eventType)
: false;
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
if (eventType.schedulingType === "MANAGED") {
throw new BadRequestException(
@@ -140,13 +144,13 @@ export class BookingsService_2024_08_13 {
await this.hasRequiredBookingFieldsResponses(body, eventType);
if (isRecurring && isSeated) {
return await this.createRecurringSeatedBooking(request, body, eventType);
return await this.createRecurringSeatedBooking(request, body, eventType, userIsEventTypeAdminOrOwner);
}
if (isRecurring && !isSeated) {
return await this.createRecurringBooking(request, body, eventType);
}
if (isSeated) {
return await this.createSeatedBooking(request, body, eventType);
return await this.createSeatedBooking(request, body, eventType, userIsEventTypeAdminOrOwner);
}
return await this.createRegularBooking(request, body, eventType);
@@ -157,7 +161,8 @@ export class BookingsService_2024_08_13 {
async checkBookingRequiresAuthenticationSetting(
eventType: EventTypeWithOwnerAndTeam,
authUser: AuthOptionalUser
authUser: AuthOptionalUser,
userIsEventTypeAdminOrOwner: boolean
) {
if (!eventType.bookingRequiresAuthentication) return true;
if (!authUser) {
@@ -166,15 +171,23 @@ export class BookingsService_2024_08_13 {
);
}
if (!userIsEventTypeAdminOrOwner) {
throw new ForbiddenException(
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
);
}
}
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType) {
const authUserId = authUser.id;
const authUserRole = authUser.role;
const eventTypeId = eventType.id;
const teamId = eventType.teamId;
const bookingUserId = eventType.owner?.id || null;
const eventTypeOwnerId = eventType.userId || null;
if (authUserRole === "ADMIN") return;
if (authUserRole === "ADMIN") return true;
if (bookingUserId === authUserId) return;
if (eventTypeOwnerId === authUserId) return true;
if (eventTypeId) {
const [isUserHost, isUserAssigned] = await Promise.all([
@@ -182,7 +195,7 @@ export class BookingsService_2024_08_13 {
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
]);
if (isUserHost || isUserAssigned) return;
if (isUserHost || isUserAssigned) return true;
}
if (teamId) {
@@ -190,19 +203,17 @@ export class BookingsService_2024_08_13 {
authUserId,
teamId
);
if (membership) return;
if (membership) return true;
}
if (
bookingUserId &&
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, bookingUserId))
eventTypeOwnerId &&
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId))
) {
return;
return true;
}
throw new ForbiddenException(
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
);
return false;
}
async getBookedEventType(body: CreateBookingInput) {
@@ -476,7 +487,8 @@ export class BookingsService_2024_08_13 {
async createRecurringSeatedBooking(
request: Request,
body: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
eventType: EventTypeWithOwnerAndTeam,
userIsEventTypeAdminOrOwner: boolean
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
@@ -491,7 +503,8 @@ export class BookingsService_2024_08_13 {
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
userIsEventTypeAdminOrOwner
);
}
@@ -528,7 +541,8 @@ export class BookingsService_2024_08_13 {
async createSeatedBooking(
request: Request,
body: CreateBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
eventType: EventTypeWithOwnerAndTeam,
userIsEventTypeAdminOrOwner: boolean
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
try {
@@ -554,7 +568,11 @@ export class BookingsService_2024_08_13 {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
return this.outputService.getOutputCreateSeatedBooking(
databaseBooking,
booking.seatReferenceUid || "",
userIsEventTypeAdminOrOwner
);
} catch (error) {
if (error instanceof Error) {
if (error.message === "booking_seats_full_error") {
@@ -565,8 +583,12 @@ export class BookingsService_2024_08_13 {
}
}
async getBooking(uid: string) {
async getBooking(uid: string, authUser: AuthOptionalUser) {
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
const userIsEventTypeAdminOrOwner =
authUser && booking?.eventType
? await this.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
: false;
if (booking) {
const isRecurring = !!booking.recurringEventId;
@@ -576,13 +598,12 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputRecurringBooking(booking);
}
if (isRecurring && isSeated) {
return this.outputService.getOutputRecurringSeatedBooking(
booking,
!!booking.eventType?.seatsShowAttendees
);
const showAttendees = userIsEventTypeAdminOrOwner || !!booking.eventType?.seatsShowAttendees;
return this.outputService.getOutputRecurringSeatedBooking(booking, showAttendees);
}
if (isSeated) {
return this.outputService.getOutputSeatedBooking(booking, !!booking.eventType?.seatsShowAttendees);
const showAttendees = userIsEventTypeAdminOrOwner || !!booking.eventType?.seatsShowAttendees;
return this.outputService.getOutputSeatedBooking(booking, showAttendees);
}
return this.outputService.getOutputBooking(booking);
}
@@ -594,10 +615,9 @@ export class BookingsService_2024_08_13 {
const ids = recurringBooking.map((booking) => booking.id);
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
if (isRecurringSeated) {
return this.outputService.getOutputRecurringSeatedBookings(
ids,
!!recurringBooking[0].eventType?.seatsShowAttendees
);
const showAttendees =
userIsEventTypeAdminOrOwner || !!recurringBooking[0].eventType?.seatsShowAttendees;
return this.outputService.getOutputRecurringSeatedBookings(ids, showAttendees);
}
return this.outputService.getOutputRecurringBookings(ids);
@@ -663,19 +683,9 @@ export class BookingsService_2024_08_13 {
if (isRecurring && !isSeated) {
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
} else if (isRecurring && isSeated) {
formattedBookings.push(
this.outputService.getOutputRecurringSeatedBooking(
formatted,
!!formatted.eventType?.seatsShowAttendees
)
);
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted, true));
} else if (isSeated) {
formattedBookings.push(
await this.outputService.getOutputSeatedBooking(
formatted,
!!formatted.eventType?.seatsShowAttendees
)
);
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted, true));
} else {
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
}
@@ -718,7 +728,12 @@ export class BookingsService_2024_08_13 {
return queryParamsAttendeeEmail;
}
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
async rescheduleBooking(
request: Request,
bookingUid: string,
body: RescheduleBookingInput,
authUser: AuthOptionalUser
) {
try {
await this.canRescheduleBooking(bookingUid);
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
@@ -747,6 +762,10 @@ export class BookingsService_2024_08_13 {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
const userIsEventTypeAdminOrOwner =
authUser && databaseBooking.eventType
? await this.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
: false;
const isRecurring = !!databaseBooking.recurringEventId;
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
@@ -756,13 +775,15 @@ export class BookingsService_2024_08_13 {
if (isRecurring && isSeated) {
return this.outputService.getOutputCreateRecurringSeatedBooking(
databaseBooking,
booking?.seatReferenceUid || ""
booking?.seatReferenceUid || "",
userIsEventTypeAdminOrOwner
);
}
if (isSeated) {
return this.outputService.getOutputCreateSeatedBooking(
databaseBooking,
booking.seatReferenceUid || ""
booking.seatReferenceUid || "",
userIsEventTypeAdminOrOwner
);
}
return this.outputService.getOutputBooking(databaseBooking);
@@ -790,7 +811,12 @@ export class BookingsService_2024_08_13 {
return booking;
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
async cancelBooking(
request: Request,
bookingUid: string,
body: CancelBookingInput,
authUser: AuthOptionalUser
) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
@@ -821,13 +847,13 @@ export class BookingsService_2024_08_13 {
}
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
return this.getAllRecurringBookingsByIndividualUid(bookingUid, authUser);
}
return this.getBooking(bookingUid);
return this.getBooking(bookingUid, authUser);
}
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
private async getAllRecurringBookingsByIndividualUid(bookingUid: string, authUser: AuthOptionalUser) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
const recurringBookingUid = booking?.recurringEventId;
if (!recurringBookingUid) {
@@ -836,7 +862,7 @@ export class BookingsService_2024_08_13 {
);
}
return await this.getBooking(recurringBookingUid);
return await this.getBooking(recurringBookingUid, authUser);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
@@ -1003,7 +1029,7 @@ export class BookingsService_2024_08_13 {
}
}
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
async confirmBooking(bookingUid: string, requestUser: ApiAuthGuardUser) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
@@ -1032,10 +1058,10 @@ export class BookingsService_2024_08_13 {
},
});
return this.getBooking(bookingUid);
return this.getBooking(bookingUid, requestUser);
}
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
async declineBooking(bookingUid: string, requestUser: ApiAuthGuardUser, reason?: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
@@ -1065,7 +1091,7 @@ export class BookingsService_2024_08_13 {
},
});
return this.getBooking(bookingUid);
return this.getBooking(bookingUid, requestUser);
}
async getCalendarLinks(bookingUid: string): Promise<CalendarLink[]> {
@@ -229,7 +229,8 @@ export class OutputBookingsService_2024_08_13 {
status: databaseBooking.status.toLowerCase(),
cancellationReason:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
cancelledByEmail:
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
@@ -269,12 +270,11 @@ export class OutputBookingsService_2024_08_13 {
async getOutputCreateSeatedBooking(
databaseBooking: DatabaseBooking,
seatUid: string
seatUid: string,
userIsEventTypeAdminOrOwner: boolean
): Promise<CreateSeatedBookingOutput_2024_08_13> {
const getSeatedBookingOutput = await this.getOutputSeatedBooking(
databaseBooking,
!!databaseBooking.eventType?.seatsShowAttendees
);
const showAttendees = userIsEventTypeAdminOrOwner || !!databaseBooking.eventType?.seatsShowAttendees;
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking, showAttendees);
return { ...getSeatedBookingOutput, seatUid };
}
@@ -377,7 +377,10 @@ export class OutputBookingsService_2024_08_13 {
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
async getOutputCreateRecurringSeatedBookings(
bookings: { uid: string; seatUid: string }[],
userIsEventTypeAdminOrOwner: boolean
) {
const transformed = [];
for (const booking of bookings) {
@@ -386,7 +389,13 @@ export class OutputBookingsService_2024_08_13 {
if (!databaseBooking) {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
transformed.push(
this.getOutputCreateRecurringSeatedBooking(
databaseBooking,
booking.seatUid,
userIsEventTypeAdminOrOwner
)
);
}
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
@@ -394,11 +403,13 @@ export class OutputBookingsService_2024_08_13 {
getOutputCreateRecurringSeatedBooking(
databaseBooking: DatabaseBooking,
seatUid: string
seatUid: string,
userIsEventTypeAdminOrOwner: boolean
): CreateRecurringSeatedBookingOutput_2024_08_13 {
const showAttendees = userIsEventTypeAdminOrOwner || !!databaseBooking.eventType?.seatsShowAttendees;
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(
databaseBooking,
!!databaseBooking.eventType?.seatsShowAttendees
showAttendees
);
return { ...getRecurringSeatedBookingOutput, seatUid };
}
@@ -1141,6 +1141,9 @@ describe("Event types Endpoints", () => {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticRecordingForOrganizer: true,
enableAutomaticTranscription: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
},
bookingFields: [
nameBookingField,
@@ -1274,6 +1277,15 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.calVideoSettings?.enableAutomaticRecordingForOrganizer).toEqual(
body.calVideoSettings?.enableAutomaticRecordingForOrganizer
);
expect(updatedEventType.calVideoSettings?.enableAutomaticTranscription).toEqual(
body.calVideoSettings?.enableAutomaticTranscription
);
expect(updatedEventType.calVideoSettings?.disableTranscriptionForGuests).toEqual(
body.calVideoSettings?.disableTranscriptionForGuests
);
expect(updatedEventType.calVideoSettings?.disableTranscriptionForOrganizer).toEqual(
body.calVideoSettings?.disableTranscriptionForOrganizer
);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -32,7 +32,7 @@ import { randomString } from "test/utils/randomString";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
import { ApiAuthGuardRequest } from "./api-auth.strategy";
import { ApiAuthGuardRequest, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -244,6 +244,29 @@ describe("ApiAuthStrategy", () => {
}
});
it("should throw 401 if only OAuth ID is provided", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
},
get: (key: string) => ({ origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
expect(error.message).toContain(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
}
}
});
it("should throw 401 if OAuth ID is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
@@ -21,7 +21,7 @@ import {
HttpStatus,
Logger,
Delete,
BadRequestException,
ParseIntPipe,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
@@ -51,7 +51,7 @@ export class BillingController {
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
async checkTeamBilling(
@Param("teamId") teamId: number
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
const { status, plan } = await this.billingService.getBillingData(teamId);
@@ -34,7 +34,7 @@ import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { User } from "@calcom/prisma/client";
import type { CreateDelegationCredentialInput } from "./inputs/create-delegation-credential.input";
import { CreateDelegationCredentialInput } from "./inputs/create-delegation-credential.input";
@Controller({
path: "/v2/organizations/:orgId/delegation-credentials",
@@ -35,7 +35,7 @@ import type {
import type { User, Team } from "@calcom/prisma/client";
describe("Organizations Event Types Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
describe("User Authentication - User is Org Admin but not team admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
File diff suppressed because it is too large Load Diff
@@ -134,7 +134,7 @@ export async function getCRMData(
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/lib/server/getTeamMemberEmailFromCrm"
"@calcom/features/ee/teams/lib/getTeamMemberEmailFromCrm"
);
const {
email,
@@ -41,8 +41,8 @@ import type {
import type { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { EventTypesByViewer } from "@calcom/features/eventtypes/lib/getEventTypesByViewer";
import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared";
import type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
import type { Prisma } from "@calcom/prisma/client";
@@ -0,0 +1,9 @@
"use client";
import { useRedirectToLoginIfUnauthenticated } from "@calcom/features/auth/lib/hooks/useRedirectToLoginIfUnauthenticated";
export function RoutingFormAuthGuard({ children }: { children: React.ReactNode }) {
useRedirectToLoginIfUnauthenticated();
return <>{children}</>;
}
@@ -1,7 +1,22 @@
import type { ReactNode } from "react";
import FormProvider from "./FormProvider";
import { RoutingFormAuthGuard } from "./RoutingFormAuthGuard";
export default async function Layout({ children }: { children: ReactNode }) {
return <FormProvider>{children}</FormProvider>;
interface LayoutProps {
children: ReactNode;
params: { pages: string[] };
}
export default async function Layout({ children, params }: LayoutProps) {
const isPublic = params.pages?.[0] === "routing-link";
if (isPublic) {
return <FormProvider>{children}</FormProvider>;
}
return (
<RoutingFormAuthGuard>
<FormProvider>{children}</FormProvider>
</RoutingFormAuthGuard>
);
}
@@ -8,6 +8,10 @@ import { z } from "zod";
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -40,6 +44,95 @@ const getCachedEventType = unstable_cache(
{ revalidate: 3600 } // Cache for 1 hour
);
const getEventPermissions = async (userId: number, teamId: number | null) => {
// Personal event - has all perms
if (!teamId)
return {
eventTypes: {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
workflows: {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
};
const membership = await prisma.membership.findFirst({
where: {
userId,
teamId,
},
select: {
role: true,
},
});
if (!membership) throw new Error("Membership not found");
const [eventTypePermissions, workflowPermissions] = await Promise.all([
getResourcePermissions({
userId,
teamId,
resource: Resource.EventType,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
getResourcePermissions({
userId,
teamId,
resource: Resource.Workflow,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
]);
return {
eventTypes: {
canRead: eventTypePermissions.canRead,
canCreate: eventTypePermissions.canCreate,
canUpdate: eventTypePermissions.canEdit,
canDelete: eventTypePermissions.canDelete,
},
workflows: {
canRead: workflowPermissions.canRead,
canCreate: workflowPermissions.canCreate,
canUpdate: workflowPermissions.canEdit,
canDelete: workflowPermissions.canDelete,
},
};
};
const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -59,7 +152,10 @@ const ServerPage = async ({ params }: PageProps) => {
throw new Error("This event type does not exist");
}
return <EventTypeWebWrapper data={data} id={eventTypeId} />;
// Fetch permissions for the event type's team
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
};
export default ServerPage;
@@ -3,7 +3,6 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { hasInsightsPermission } from "@calcom/features/insights/server/hasInsightsPermission";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -21,14 +20,5 @@ export async function checkInsightsPagePermission() {
redirect("/auth/login");
}
const hasPermission = await hasInsightsPermission({
userId: session.user.id,
organizationId: session.user.org?.id,
});
if (!hasPermission) {
redirect("/");
}
return session;
}
@@ -141,7 +141,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="col-span-2 mb-6 font-semibold">
<Price
currency={paymentAppData.currency}
price={paymentAppData.price}
price={props.payment?.amount ?? paymentAppData.price}
displayAlternateSymbol={false}
/>
</div>
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -26,11 +25,10 @@ const WebhooksViewServerWrapper = async () => {
redirect("/auth/login");
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
return <WebhooksView data={data} isAdmin={isAdmin} />;
return <WebhooksView data={data} />;
};
export default WebhooksViewServerWrapper;
@@ -26,7 +26,7 @@ const Page = async () => {
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={true}>
borderInShellHeader={false}>
<BillingView />
</SettingsHeader>
);
@@ -107,9 +107,9 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
const { isAdvancedMode, permissions, color } = form.watch();
const filteredResources = useMemo(() => {
const { filteredResources, scopedRegistry } = useMemo(() => {
const scopedRegistry = getPermissionsForScope(scope);
return Object.keys(scopedRegistry).filter((resource) =>
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
t(
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
?.i18nKey || ""
@@ -117,6 +117,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
return { filteredResources, scopedRegistry };
}, [searchQuery, t, scope]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
@@ -212,7 +213,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
disabled={isSystemRole}
/>
</div>
{filteredResources.map((resource) => (
<AdvancedPermissionGroup
key={resource}
@@ -222,7 +222,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
disabled={isSystemRole}
scope={scope}
/>
))}
))}{" "}
</div>
) : (
<div className="bg-muted rounded-xl p-1">
@@ -237,7 +237,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
</div>
</div>
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
{Object.keys(getPermissionsForScope(scope)).map((resource) => (
{Object.keys(scopedRegistry).map((resource) => (
<SimplePermissionItem
key={resource}
resource={resource}
@@ -247,7 +247,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
scope={scope}
/>
))}
</div>
</div>{" "}
</div>
)}
</div>
@@ -1,7 +1,11 @@
"use client";
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import {
Scope,
PERMISSION_REGISTRY,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
@@ -21,11 +25,13 @@ export function SimplePermissionItem({
permissions,
onChange,
disabled,
scope,
scope = Scope.Organization,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const registry = scopedRegistry || PERMISSION_REGISTRY;
const isAllResources = resource === "*";
const options = isAllResources
? [
@@ -41,7 +47,7 @@ export function SimplePermissionItem({
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-default text-sm font-medium leading-none">
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
{t(registry[resource as Resource]._resource?.i18nKey || resource)}
</span>
<ToggleGroup
onValueChange={(val) => {
@@ -1,8 +1,5 @@
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import {
PERMISSION_REGISTRY,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { getPermissionsForScope } from "@calcom/features/pbac/domain/types/permission-registry";
import {
getTransitiveDependencies,
getTransitiveDependents,
@@ -52,7 +49,8 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
return permissions.includes("*.*") ? "all" : "none";
}
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 "none";
// Check if global all permissions (*.*) is present
@@ -111,7 +109,19 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
allResourcePerms = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_"))
.map((action) => `${resource}.${action}`);
// Add the resource permissions
newPermissions.push(...allResourcePerms);
// Add all transitive dependencies for each permission
allResourcePerms.forEach((perm) => {
const dependencies = getTransitiveDependencies(perm, scope);
dependencies.forEach((dep) => {
if (!newPermissions.includes(dep)) {
newPermissions.push(dep);
}
});
});
break;
}
@@ -132,15 +142,12 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
// First, remove *.* since we're modifying individual permissions
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
// Parse the permission to get resource and action
const [resource, action] = permission.split(".");
if (enabled) {
// Add the requested permission
newPermissions.push(permission);
// Add all transitive dependencies
const dependencies = getTransitiveDependencies(permission);
const dependencies = getTransitiveDependencies(permission, scope);
dependencies.forEach((dependency) => {
if (!newPermissions.includes(dependency)) {
newPermissions.push(dependency);
@@ -151,7 +158,7 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
newPermissions = newPermissions.filter((p) => p !== permission);
// Remove all transitive dependents
const dependents = getTransitiveDependents(permission);
const dependents = getTransitiveDependents(permission, scope);
dependents.forEach((dependent) => {
newPermissions = newPermissions.filter((p) => p !== dependent);
});
@@ -11,7 +11,7 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -19,13 +19,13 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const WORKFLOW_TEMPLATES = {
"wf-10": {
name: "Cal AI No-show Follow-up Call",
name: "Cal.ai No-show Follow-up Call",
trigger: WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED,
time: 0,
timeUnit: TimeUnit.MINUTE,
},
"wf-11": {
name: "Cal AI 1-hour Meeting Reminder",
name: "Cal.ai 1-hour Meeting Reminder",
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
@@ -4,8 +4,8 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
+2 -2
View File
@@ -4,8 +4,8 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { emailRegex } from "@calcom/lib/emailSchema";
import { HttpError } from "@calcom/lib/http-error";
import slugify from "@calcom/lib/slugify";
@@ -6,7 +6,7 @@ import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/lib/CalendarManager";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/features/calendars/lib/CalendarManager";
import { HttpError } from "@calcom/lib/http-error";
import notEmpty from "@calcom/lib/notEmpty";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
@@ -17,7 +17,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import {
getAllTranscriptsAccessLinkFromMeetingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/lib/videoClient";
} from "@calcom/app-store/videoClient";
import { generateVideoToken } from "@calcom/lib/videoTokens";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { describe, expect, test, vi, afterEach } from "vitest";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
import { GET } from "../route";
vi.mock("@calcom/lib/videoClient", () => ({
vi.mock("@calcom/app-store/videoClient", () => ({
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
export async function GET(request: Request) {
@@ -7,9 +7,10 @@ import { useFieldArray, useFormContext } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
import type { LocationObject } from "@calcom/app-store/locations";
import { locationsResolver } from "@calcom/app-store/locations";
import NoSSR from "@calcom/lib/components/NoSSR";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { LocationObject } from "@calcom/lib/location";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -188,7 +189,7 @@ const EventTypeGroup = ({
);
};
export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
const { loading, formPortalRef, handleSetUpLater } = props;
const { t } = useLocale();
const { control, watch } = useFormContext<TEventTypesForm>();
@@ -212,6 +213,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
);
const [submit, setSubmit] = useState(false);
const [mounted, setMounted] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
useEffect(() => {
@@ -221,60 +223,73 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
}
}, [submit, allUpdated, mainForSubmitRef]);
return (
formPortalRef?.current &&
createPortal(
<div className="mt-8">
{fields.map((group, groupIndex) => (
<div key={group.fieldId}>
{eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && (
<div className="mb-2 mt-4 flex items-center">
<Avatar
alt=""
imageSrc={group.image} // if no image, use default avatar
size="md"
className="inline-flex justify-center"
/>
<p className="text-subtle block pl-2">{group.slug}</p>
</div>
)}
<EventTypeGroup
groupIndex={groupIndex}
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
submitRefs={submitRefs}
{...props}
/>
</div>
))}
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
Save
</button>
<Button
className="text-md mt-6 w-full justify-center"
type="button"
data-testid="configure-step-save"
onClick={() => {
submitRefs.current.forEach((ref) => ref?.click());
setSubmit(true);
}}
loading={loading}>
{t("save")}
</Button>
useEffect(() => {
setMounted(true);
}, []);
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
if (!formPortalRef?.current || !mounted) {
return null;
}
return createPortal(
<div className="mt-8">
{fields.map((group, groupIndex) => (
<div key={group.fieldId}>
{eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && (
<div className="mb-2 mt-4 flex items-center">
<Avatar
alt=""
imageSrc={group.image} // if no image, use default avatar
size="md"
className="inline-flex justify-center"
/>
<p className="text-subtle block pl-2">{group.slug}</p>
</div>
)}
<EventTypeGroup
groupIndex={groupIndex}
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
submitRefs={submitRefs}
{...props}
/>
</div>
</div>,
formPortalRef?.current
)
))}
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
Save
</button>
<Button
className="text-md mt-6 w-full justify-center"
type="button"
data-testid="configure-step-save"
onClick={() => {
submitRefs.current.forEach((ref) => ref?.click());
setSubmit(true);
}}
loading={loading}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>,
formPortalRef.current
);
};
export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
return (
<NoSSR>
<ConfigureStepCardContent {...props} />
</NoSSR>
);
};
@@ -2,7 +2,7 @@ import { useEffect, type FC } from "react";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import useAppsData from "@calcom/lib/hooks/useAppsData";
import useAppsData from "@calcom/features/apps/hooks/useAppsData";
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
@@ -164,6 +164,7 @@ function BookingListItem(booking: BookingItemProps) {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
+1 -1
View File
@@ -5,7 +5,7 @@ import React from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import type { TeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import type { UserProfile } from "@calcom/types/UserProfile";
import { UserAvatar } from "@calcom/ui/components/avatar";
@@ -5,9 +5,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import {
ROUTING_FORM_RESPONSE_ID_QUERY_STRING,
ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING,
} from "@calcom/app-store/routing-forms/lib/constants";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "@calcom/lib/server/getTeamMemberEmailFromCrm";
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "@calcom/features/ee/teams/lib/getTeamMemberEmailFromCrm";
import { SchedulingType } from "@calcom/prisma/enums";
vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({
@@ -52,24 +55,22 @@ function mockBookingFormHandler({
);
}
async function createRoutingFormWithResponse({
formData,
}: {
formData: {
routes: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue?: any;
action: {
eventTypeId: number;
type: RouteActionType;
value: string;
};
attributeRoutingConfig: {
skipContactOwner: boolean;
};
}[];
};
}) {
type RoutingFormData = {
routes: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue?: any;
action: {
eventTypeId: number;
type: RouteActionType;
value: string;
};
attributeRoutingConfig: {
skipContactOwner: boolean;
};
}[];
};
async function _createRoutingFormBase({ formData }: { formData: RoutingFormData }) {
const user = await prismock.user.create({
data: {
name: "Routing Form User",
@@ -100,6 +101,12 @@ async function createRoutingFormWithResponse({
},
});
return { form, routeId };
}
async function createRoutingFormWithResponse({ formData }: { formData: RoutingFormData }) {
const { form, routeId } = await _createRoutingFormBase({ formData });
const responseRecord = await prismock.app_RoutingForms_FormResponse.create({
data: {
chosenRouteId: routeId,
@@ -115,6 +122,26 @@ async function createRoutingFormWithResponse({
return { form, responseRecord };
}
async function createRoutingFormWithQueuedResponse({ formData }: { formData: RoutingFormData }) {
const { form, routeId } = await _createRoutingFormBase({ formData });
const queuedResponseId = v4();
const queuedResponseRecord = await prismock.app_RoutingForms_QueuedFormResponse.create({
data: {
id: queuedResponseId,
chosenRouteId: routeId,
response: {},
form: {
connect: {
id: form.id,
},
},
},
});
return { form, queuedResponseRecord };
}
async function createHostForEvent({
eventTypeData,
userData,
@@ -254,166 +281,250 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
});
describe("Booking form handler", () => {
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
describe("when responseId is provided", () => {
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result.email).toBe(teamMemberEmail);
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: true },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
// Because skipContactOwner is true, the booking form handler should return null
expect(result.email).toBe(null);
});
expect(result.email).toBe(teamMemberEmail);
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "ownerNotInEventType@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
const SOME_OTHER_EVENT_ID = 200;
await createHostForEvent({
eventTypeData: {
id: SOME_OTHER_EVENT_ID,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result.email).toBe(null);
});
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.somethingelse.xxx": "true",
},
eventData: mockEventData,
});
expect(result.email).toBe(null);
});
});
describe("when queuedFormResponseId is provided", () => {
it("should return teamMember email through booking form handler when cal.queuedFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
const { queuedResponseRecord } = await createRoutingFormWithQueuedResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
attributeRoutingConfig: { skipContactOwner: true },
},
],
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]: queuedResponseRecord.id,
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result.email).toBe(teamMemberEmail);
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
it("should return null when skipContactOwner is true even when cal.queuedFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
// Because skipContactOwner is true, the booking form handler should return null
expect(result.email).toBe(null);
});
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "ownerNotInEventType@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
const SOME_OTHER_EVENT_ID = 200;
await createHostForEvent({
eventTypeData: {
id: SOME_OTHER_EVENT_ID,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
const { queuedResponseRecord } = await createRoutingFormWithQueuedResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: true },
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]: queuedResponseRecord.id,
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
// Because skipContactOwner is true, the booking form handler should return null
expect(result.email).toBe(null);
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result.email).toBe(null);
});
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.somethingelse.xxx": "true",
},
eventData: mockEventData,
});
expect(result.email).toBe(null);
});
});
});
@@ -2,13 +2,13 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { LocationObject } from "@calcom/app-store/locations";
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import type { LocationObject } from "@calcom/lib/location";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -14,6 +14,7 @@ export const useCheckTeamBilling = (teamId?: number | null, isPlatformTeam?: boo
return data.data;
},
enabled: !!teamId && !!isPlatformTeam,
staleTime: 5000,
});
return isTeamBilledAlready;
@@ -4,14 +4,12 @@ import { URLSearchParams } from "url";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getFullName } from "@calcom/features/form-builder/utils";
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { getSafe } from "@calcom/lib/getSafe";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
const querySchema = z.object({
uid: z.string(),
@@ -63,6 +61,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
allowReschedulingCancelledBookings: true,
team: {
select: {
id: true,
parentId: true,
slug: true,
},
@@ -98,7 +97,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
notFound: true,
} as const;
}
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const userRepo = new UserRepository(prisma);
@@ -112,59 +110,36 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profileEnrichedBookingUser: enrichedBookingUser,
});
const isForcedRescheduleForCancelledBooking = allowRescheduleForCancelledBooking;
// If booking is already REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details.
// If the booking is CANCELLED and allowRescheduleForCancelledBooking is false, we redirect the user to the original event link.
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
// This comes from query param and thus is considered forced
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
const isNonRescheduleableBooking =
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
if (isDisabledRescheduling) {
return {
redirect: {
destination: `/booking/${uid}`,
permanent: false,
},
};
}
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
const canReschedule =
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
return {
redirect: {
destination: canReschedule ? eventUrl : `/booking/${uid}`,
permanent: false,
},
};
}
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
// TODO: Show something in UI to let user know that this booking is not rescheduleable
return {
notFound: true,
} as {
notFound: true;
};
} as const;
}
const isBookingInPast = booking.endTime && new Date(booking.endTime) < new Date();
if (isBookingInPast && !eventType.allowReschedulingPastBookings) {
const destinationUrlSearchParams = new URLSearchParams();
const responses = bookingSeat ? getSafe<string>(bookingSeat.data, ["responses"]) : booking.responses;
const name = getFullName(getSafe<string | { firstName: string; lastName?: string }>(responses, ["name"]));
const email = getSafe<string>(responses, ["email"]);
// Check if reschedule should be prevented based on booking status and event type settings
const reschedulePreventionRedirectUrl = determineReschedulePreventionRedirect({
booking: {
uid,
status: booking.status,
endTime: booking.endTime,
responses: booking.responses,
eventType: {
disableRescheduling: !!eventType?.disableRescheduling,
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
teamId: eventType.team?.id ?? null,
},
},
eventUrl,
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
bookingSeat,
});
if (name) destinationUrlSearchParams.set("name", name);
if (email) destinationUrlSearchParams.set("email", email);
const searchParamsString = destinationUrlSearchParams.toString();
if (reschedulePreventionRedirectUrl) {
return {
redirect: {
destination: searchParamsString ? `${eventUrl}?${searchParamsString}` : eventUrl,
destination: reschedulePreventionRedirectUrl,
permanent: false,
},
};
@@ -108,7 +108,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/lib/server/getTeamMemberEmailFromCrm"
"@calcom/features/ee/teams/lib/getTeamMemberEmailFromCrm"
);
const {
email,
@@ -11,7 +11,7 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
@@ -15,7 +15,7 @@ import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { LocationObject } from "@calcom/lib/location";
import type { LocationObject } from "@calcom/app-store/locations";
import type { Team } from "@calcom/prisma/client";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -6,7 +6,7 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -27,7 +27,7 @@ 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 { MembershipRole } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -954,6 +954,24 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
userEventGroupsData?.profiles
?.filter((profile) => !profile.readOnly)
?.filter((profile) => !profile.eventTypesLockedByOrg)
?.filter((profile) => {
// For personal profiles (teamId is null), always allow creation
if (!profile.teamId) {
return true;
}
// For team profiles, check if user has eventType.create permission
// This will be populated by the server-side PBAC check
// Fallback to role-based check (admin/owner) if canCreateEventTypes is not set
if (profile.canCreateEventTypes !== undefined) {
return profile.canCreateEventTypes;
}
// Fallback: allow admin and owner roles
return (
profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER
);
})
?.map((profile) => {
return {
teamId: profile.teamId,
+29 -2
View File
@@ -1,5 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import {
DataTableProvider,
DataTableFilters,
@@ -7,6 +9,7 @@ import {
ColumnFilterType,
type FilterableColumn,
} from "@calcom/features/data-table";
import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import {
AverageEventDurationChart,
@@ -27,11 +30,13 @@ import {
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { DateTargetSelector, type DateTarget } from "@calcom/features/insights/filters/DateTargetSelector";
import { Download } from "@calcom/features/insights/filters/Download";
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsightsBookings";
import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
export default function InsightsPage({ timeZone }: { timeZone: string }) {
return (
@@ -49,10 +54,26 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
type: ColumnFilterType.DATE_RANGE,
};
const startTimeColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
id: "startTime",
title: "startTime",
type: ColumnFilterType.DATE_RANGE,
};
function InsightsPageContent() {
const { t } = useLocale();
const { table } = useInsightsBookings();
const { isAll, teamId, userId } = useInsightsOrgTeams();
const { removeFilter } = useDataTable();
const [dateTarget, _setDateTarget] = useState<"startTime" | "createdAt">("startTime");
const setDateTarget = useCallback(
(target: "startTime" | "createdAt") => {
_setDateTarget(target);
removeFilter(target === "startTime" ? "createdAt" : "startTime");
},
[_setDateTarget, removeFilter]
);
return (
<>
@@ -63,10 +84,16 @@ function InsightsPageContent() {
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
<div className="grow" />
<Download />
<DateRangeFilter column={createdAtColumn} />
<ButtonGroup combined>
<DateRangeFilter
column={dateTarget === "startTime" ? startTimeColumn : createdAtColumn}
options={{ convertToTimeZone: true }}
/>
<DateTargetSelector value={dateTarget as DateTarget} onChange={setDateTarget} />
</ButtonGroup>
<TimezoneBadge />
</div>
@@ -77,21 +77,26 @@ const BillingView = () => {
return (
<>
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<Button color="primary" href={billingHref} target="_blank" EndIcon="external-link">
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
<div className="bg-default border-muted flex rounded-[10px] border px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h3 className="text-emphasis text-sm font-semibold leading-none">{t("manage_billing")}</h3>
<p className="text-subtle text-sm font-medium leading-tight">
{t("view_and_manage_billing_details")}
</p>
</div>
<Button color="primary" href={billingHref} target="_blank" size="sm" EndIcon="external-link">
{t("billing_portal")}
</Button>
</CtaRow>
</div>
<BillingCredits />
<div className="border-subtle mt-6 space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
<Button color="secondary" onClick={onContactSupportClick}>
</div>
<div className="flex items-center justify-between px-4 py-5">
<p className="text-subtle text-sm font-medium leading-tight">{t("need_help")}</p>
<Button color="secondary" size="sm" onClick={onContactSupportClick}>
{t("contact_support")}
</Button>
</CtaRow>
</div>
</div>
<BillingCredits />
</>
);
};
@@ -8,17 +8,21 @@ import { useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { ProgressBar } from "@calcom/ui/components/progress-bar";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
@@ -29,6 +33,39 @@ type MonthOption = {
endDate: string;
};
type CreditRowProps = {
label: string;
value: number;
isBold?: boolean;
underline?: "dashed" | "solid";
className?: string;
};
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
const numberFormatter = new Intl.NumberFormat();
return (
<div
className={classNames(
`my-1 flex justify-between`,
underline === "dashed"
? "border-subtle border-b border-dashed"
: underline === "solid"
? "border-subtle border-b border-solid"
: "mt-1",
className
)}>
<span
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
{label}
</span>
<span
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
{numberFormatter.format(value)}
</span>
</div>
);
};
const getMonthOptions = (): MonthOption[] => {
const options: MonthOption[] = [];
const minDate = dayjs.utc("2025-05-01");
@@ -60,6 +97,7 @@ export default function BillingCredits() {
const monthOptions = useMemo(() => getMonthOptions(), []);
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
const [isDownloading, setIsDownloading] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const utils = trpc.useUtils();
const {
@@ -71,6 +109,7 @@ export default function BillingCredits() {
const params = useParamsWithFallback();
const orgId = session.data?.user?.org?.id;
const orgSlug = session.data?.user?.org?.slug;
const parsedTeamId = Number(params.id);
const teamId: number | undefined = Number.isFinite(parsedTeamId)
@@ -132,117 +171,153 @@ export default function BillingCredits() {
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
};
const teamCreditsPercentageUsed =
creditsData.credits.totalMonthlyCredits > 0
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
: 0;
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
const numberFormatter = new Intl.NumberFormat();
return (
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
<div>
<h2 className="text-base font-semibold">{t("credits")}</h2>
<ServerTrans
t={t}
i18nKey="view_and_manage_credits_description"
components={[
<Link
key="Credit System"
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/help/billing-and-usage/messaging-credits">
Learn more
</Link>,
]}
/>
<div className="-mx-6 mt-6">
<hr className="border-subtle" />
<>
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
<div className="flex flex-col gap-1 px-4 py-5">
<h2 className="text-default text-base font-semibold leading-none">{t("credits")}</h2>
<p className="text-subtle text-sm font-medium leading-tight">{t("view_and_manage_credits")}</p>
</div>
<div className="mt-6">
{creditsData.credits.totalMonthlyCredits > 0 ? (
<div className="mb-4">
<Label>{t("monthly_credits")}</Label>
<ProgressBar
color="green"
percentageValue={teamCreditsPercentageUsed}
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
/>
<div className="text-subtle">
<div>
{t("total_credits", {
totalCredits: creditsData.credits.totalMonthlyCredits,
})}
<div className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
<div className="w-full">
{totalCredits > 0 ? (
<>
<div className="mb-4">
<CreditRow
label={t("monthly_credits")}
value={totalCredits}
isBold={true}
underline="dashed"
/>
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
<CreditRow
label={t("total_credits_remaining")}
value={creditsData.credits.totalRemainingMonthlyCredits}
/>
<div className="mt-4">
<ProgressBar color="green" percentageValue={100 - teamCreditsPercentageUsed} />
</div>
{/*750 credits per tip*/}
<div className="mt-4 flex flex-1 items-center justify-between">
<p className="text-subtle text-sm font-medium leading-tight">
{orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")}
</p>
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
{t("add_members_no_ellipsis")}
</Button>
</div>
</div>
<div>
{t("remaining_credits", {
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
})}
<div className="-mx-5 mt-5">
<hr className="border-subtle" />
</div>
</>
) : (
<></>
)}
{/*Auto Top-Up goes here when we have it*/}
{/*<div className="-mx-5 mt-5">
<hr className="border-subtle" />
</div>*/}
{/*Additional Credits*/}
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 flex">
<div className="-mb-1 mr-auto w-full">
<div className="flex justify-between">
<Label>{t("additional_credits")}</Label>
<div className="mb-2 flex items-center gap-1">
<p className="text-sm font-semibold leading-none">
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
{numberFormatter.format(creditsData.credits.additionalCredits)}
</p>
<Tooltip content={t("view_additional_credits_expense_tip")}>
<Icon name="info" className="text-emphasis h-3 w-3" />
</Tooltip>
</div>
</div>
<div className="flex w-full items-center gap-2">
<TextField
required
type="number"
{...register("quantity", {
required: t("error_required_field"),
min: { value: 50, message: t("minimum_of_credits_required") },
valueAsNumber: true,
})}
label=""
containerClassName="w-full -mt-1"
size="sm"
onChange={(e) => setValue("quantity", Number(e.target.value))}
min={50}
addOnSuffix={<>{t("credits")}</>}
/>
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
{t("buy")}
</Button>
</div>
</div>
</div>
) : (
<></>
)}
<Label>
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
</Label>
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex">
<div className="-mb-1 mr-auto">
<Label>{t("buy_additional_credits")}</Label>
<div className="flex flex-col">
<TextField
required
type="number"
{...register("quantity", {
required: t("error_required_field"),
min: { value: 50, message: t("minimum_of_credits_required") },
valueAsNumber: true,
})}
label=""
containerClassName="w-60"
onChange={(e) => setValue("quantity", Number(e.target.value))}
min={50}
addOnSuffix={<>{t("credits")}</>}
/>
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
</div>
</form>
<div className="-mx-5 mt-5">
<hr className="border-subtle" />
</div>
<div className="mt-auto">
<Button
color="primary"
target="_blank"
EndIcon="external-link"
type="submit"
data-testid="buy-credits">
{t("buy_credits")}
</Button>
</div>
</form>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<div className="flex">
<div className="mr-auto">
<Label className="mb-4">{t("download_expense_log")}</Label>
<div className="mt-2 flex flex-col">
<Select
options={monthOptions}
value={selectedMonth}
onChange={(option) => option && setSelectedMonth(option)}
/>
{/*Download Expense Log*/}
<div className="mt-4 flex">
<div className="mr-auto w-full">
<Label className="mb-4">{t("download_expense_log")}</Label>
<div className="mr-2 mt-1">
<Select
size="sm"
className="w-full"
innerClassNames={{
control: "font-medium text-emphasis",
}}
options={monthOptions}
value={selectedMonth}
onChange={(option) => option && setSelectedMonth(option)}
/>
</div>
</div>
<div className="mt-auto">
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
{t("download")}
</Button>
</div>
</div>
<div className="mt-auto">
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
{t("download")}
</Button>
</div>
</div>
</div>
{/*Credit Worth Section*/}
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
<ServerTrans
t={t}
i18nKey="credit_worth_description"
components={[
<Link
key="Credit System"
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/help/billing-and-usage/messaging-credits">
Learn more
</Link>,
]}
/>
</div>
</div>
</div>
{teamId && (
<MemberInvitationModalWithoutMembers
teamId={teamId}
showMemberInvitationModal={showMemberInvitationModal}
hideInvitationModal={() => setShowMemberInvitationModal(false)}
onSettingsOpen={() => {
return;
}}
/>
)}
</>
);
}
@@ -3,77 +3,76 @@ import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/comp
export function BillingCreditsSkeleton() {
return (
<SkeletonContainer>
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 text-sm sm:space-y-8">
{/* Title and Description */}
<div>
<div>
<SkeletonText className="mb-2 h-4 w-20" /> {/* Credits title */}
</div>
<div>
<SkeletonText className="h-4 w-72" /> {/* Description */}
</div>
<div className="-mx-6 mt-6">
<hr className="border-subtle" />
</div>
{/* Monthly credits */}
<div className="mt-[20px]">
<SkeletonText className="mb-4 h-4 w-32" /> {/* Monthly credits label */}
<div className="h-2 w-full rounded-md bg-gray-200" /> {/* Progress bar */}
<div className="text-subtle mt-2">
<div>
<SkeletonText className="h-4 w-40" /> {/* Total credits */}
<div className="bg-muted border-muted mt-5 rounded-xl p-1">
<div className="flex flex-col gap-1 px-4 py-5">
<SkeletonText className="h-4 w-20" /> {/* Credits title */}
<SkeletonText className="h-4 w-48" /> {/* View and manage credits description */}
</div>
<div className="bg-default border-muted flex w-full rounded-[10px] px-5 py-4">
<div className="w-full">
{/* Credits section */}
<div className="mb-4">
{/* Monthly credits row */}
<div className="mt-1 flex justify-between">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-4 w-20" />
</div>
<div>
<SkeletonText className="h-4 w-40" /> {/* Remaining credits */}
{/* Additional credits row */}
<div className="mt-1 flex justify-between">
<SkeletonText className="h-4 w-36" />
<SkeletonText className="h-4 w-20" />
</div>
{/* Remaining row */}
<div className="mt-1 flex justify-between">
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-20" />
</div>
{/* Progress bar */}
<div className="mt-4">
<div className="h-2 w-full rounded-md bg-gray-200" />
</div>
{/* Credits per tip */}
<div className="mt-4 flex flex-1 justify-between">
<SkeletonText className="h-4 w-32" />
<SkeletonButton className="h-8 w-24 rounded-md" />
</div>
</div>
<div className="-mx-5 mt-5">
<hr className="border-subtle" />
</div>
{/* Additional Credits form */}
<div className="mt-4 flex">
<div className="-mb-1 mr-auto w-full">
<SkeletonText className="mb-2 h-4 w-32" /> {/* Additional credits label */}
<div className="flex w-full">
<SkeletonText className="h-9 w-full rounded-md" /> {/* Input field */}
</div>
</div>
<div className="ml-2 mt-auto">
<SkeletonButton className="h-9 w-12 rounded-md" /> {/* Buy button */}
</div>
</div>
<div className="-mx-5 mt-5">
<hr className="border-subtle" />
</div>
{/* Download Expense Log */}
<div className="mt-4 flex">
<div className="mr-auto w-full">
<SkeletonText className="mb-4 h-4 w-40" /> {/* Download expense log label */}
<div className="mr-2 mt-1">
<SkeletonText className="h-9 w-full rounded-md" /> {/* Select dropdown */}
</div>
</div>
<div className="mt-auto">
<SkeletonButton className="h-9 w-20 rounded-md" /> {/* Download button */}
</div>
</div>
</div>
{/* Additional credits */}
<div className="mt-4">
<div>
<SkeletonText className="mb-2 h-4 w-40" /> {/* Additional credits label */}
</div>
<div>
<SkeletonText className="h-4 w-24" /> {/* Additional credits value */}
</div>
</div>
<div className="-mx-6 mb-6 mt-4">
<hr className="border-subtle mb-3 mt-3" />
</div>
{/* Buy credits form */}
<div className="flex">
<div className="mr-auto">
<div>
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
</div>
<div>
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
</div>
</div>
<div className="mt-auto ">
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
</div>
</div>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
{/* Download expense log */}
<div className="flex">
<div className="mr-auto mt-1">
<div>
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
</div>
<div>
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
</div>
</div>
<div className="mt-auto ">
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
</div>
</div>
</div>
{/* Credit Worth Section */}
<div className="text-subtle px-5 py-4">
<SkeletonText className="h-4 w-full" />
<SkeletonText className="mt-1 h-4 w-3/4" />
</div>
</div>
</SkeletonContainer>
+5 -4
View File
@@ -1,13 +1,13 @@
{
"name": "@calcom/web",
"version": "5.6.20",
"version": "5.7.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
"analyze:server": "BUNDLE_ANALYZE=server next build",
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "yarn copy-static && next dev --turbopack",
"dev": "turbo run copy-app-store-static && next dev --turbopack",
"dev:scan": "yarn dev & npx --yes react-scan@latest localhost:3000",
"dev:cron": "npx tsx cron-tester.ts",
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
@@ -16,8 +16,9 @@
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"sentry:release": "NODE_OPTIONS='--max-old-space-size=6144' node scripts/create-sentry-release.js",
"copy-static": "node scripts/copy-app-store-static.js",
"build": "yarn copy-static && next build && yarn sentry:release",
"copy-static": "turbo run copy-app-store-static",
"copy-app-store-static": "node scripts/copy-app-store-static.js",
"build": "turbo run copy-app-store-static && next build && yarn sentry:release",
"start": "next start",
"lint": "eslint . --ignore-path .gitignore",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
+69
View File
@@ -0,0 +1,69 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Admin Users Management", () => {
test("should add a new user successfully", async ({ page, users }) => {
const adminUser = await users.create({
role: "ADMIN",
});
await adminUser.apiLogin();
await page.goto("/settings/admin/users/add");
await page.waitForLoadState();
await page.fill('input[name="name"]', "Test User");
await page.fill('input[name="username"]', "testuser");
await page.fill('input[name="email"]', "testuser@example.com");
await page.fill('input[name="bio"]', "Test user bio");
await page.locator('[data-testid="timezone-select"] div').first().click();
const responsePromise = page.waitForResponse((response) =>
response.url().includes("/api/trpc/users/add")
);
await page.click('button[type="submit"]');
const response = await responsePromise;
expect(response.status()).toBe(200);
await page.waitForURL("/settings/admin/users");
});
test("should edit an existing user successfully", async ({ page, users }) => {
const adminUser = await users.create({
role: "ADMIN",
});
const userToEdit = await users.create({
name: "Edit User",
username: "edituser",
});
await adminUser.apiLogin();
await page.goto(`/settings/admin/users/${userToEdit.id}/edit`);
await page.waitForLoadState();
await expect(page.locator('input[name="name"]')).toHaveValue("Edit User");
await page.fill('input[name="name"]', "Updated User");
const responsePromise = page.waitForResponse((response) =>
response.url().includes("/api/trpc/users/update")
);
await page.click('button[type="submit"]');
const response = await responsePromise;
expect(response.status()).toBe(200);
await page.waitForURL("/settings/admin/users");
});
});
@@ -16,8 +16,8 @@ test.describe("Duplicate API Calls Prevention", () => {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
// Intercept tRPC getSchedule calls - pattern matches trpc.viewer.slots.getSchedule.useQuery()
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
// Intercept tRPC getSchedule calls - pattern matches /api/trpc/slots/getSchedule
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
@@ -33,14 +33,10 @@ test.describe("Duplicate API Calls Prevention", () => {
const totalCalls = trpcCalls.length + apiV2Calls.length;
if (totalCalls === 0) {
console.log("No API calls detected - environment may have issues preventing page load");
expect(totalCalls).toBeGreaterThanOrEqual(0);
} else {
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
}
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for team events", async ({
@@ -65,7 +61,7 @@ test.describe("Duplicate API Calls Prevention", () => {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
@@ -81,14 +77,10 @@ test.describe("Duplicate API Calls Prevention", () => {
const totalCalls = trpcCalls.length + apiV2Calls.length;
if (totalCalls === 0) {
console.log("No API calls detected - environment may have issues preventing page load");
expect(totalCalls).toBeGreaterThanOrEqual(0);
} else {
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
}
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for organization team events", async ({
@@ -117,7 +109,7 @@ test.describe("Duplicate API Calls Prevention", () => {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
@@ -133,13 +125,9 @@ test.describe("Duplicate API Calls Prevention", () => {
const totalCalls = trpcCalls.length + apiV2Calls.length;
if (totalCalls === 0) {
console.log("No API calls detected - environment may have issues preventing page load");
expect(totalCalls).toBeGreaterThanOrEqual(0);
} else {
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
}
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
});
});
+3
View File
@@ -111,6 +111,9 @@ export function createAppsFixture(page: Page) {
},
goToEventType: async (eventType: string) => {
await page.getByRole("link", { name: eventType }).click();
// fix the race condition
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-basics")).toHaveAttribute("aria-current", "page");
},
goToAppsTab: async () => {
await page.getByTestId("vertical-tab-apps").click();
+8
View File
@@ -584,6 +584,10 @@ export const createUsersFixture = (
get: () => store.users,
logout: async () => {
await page.goto("/auth/logout");
const logoutBtn = page.getByTestId("logout-btn");
await expect(logoutBtn).toHaveText("Go back to the login page");
await page.reload();
await expect(logoutBtn).toHaveText("Go back to the login page");
},
deleteAll: async () => {
const ids = store.users.map((u) => u.id);
@@ -680,6 +684,10 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
},
logout: async () => {
await page.goto("/auth/logout");
const logoutBtn = page.getByTestId("logout-btn");
await expect(logoutBtn).toHaveText("Go back to the login page");
await page.reload();
await expect(logoutBtn).toHaveText("Go back to the login page");
},
getFirstTeamMembership: async () => {
const memberships = await prisma.membership.findMany({
+113 -1
View File
@@ -1,10 +1,13 @@
import { expect } from "@playwright/test";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { clearFilters, applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
import { createAllPermissionsArray, enablePBACForTeam } from "./lib/test-helpers/pbac";
test.describe.configure({ mode: "parallel" });
@@ -237,4 +240,113 @@ test.describe("Insights", async () => {
// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs("./" + "test-insights.csv");
});
test("should render all ChartCard components with expected titles", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
const expectedChartTitles = [
"Events",
"Performance",
"Event Trends",
"Bookings by Hour",
"Average Event Duration",
"Most Bookings Scheduled",
"Least Bookings Scheduled",
"Most Bookings Completed",
"Least Bookings Completed",
"Most Cancelled",
"Most No-Show",
"Recent No-Show Guests",
"Highest Rated",
"Lowest Rated",
"Recent Ratings",
"Popular Events",
];
for (const title of expectedChartTitles) {
const chartCard = page
.locator("[data-testid='panel-card'] h2")
.filter({ hasText: new RegExp(`^${title}$`) });
await expect(chartCard).toBeVisible();
}
});
test("should be able to access insights page with custom role lacking insights.read permission", async ({
page,
users,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
});
const userOne = await users.create();
const userTwo = await users.create();
const { teamOne } = await createTeamsAndMembership(userOne.id, userTwo.id);
const orgMembership = await owner.getOrgMembership();
const orgId = orgMembership.team.id;
await enablePBACForTeam(orgId);
await enablePBACForTeam(teamOne.id);
const permissions = createAllPermissionsArray().filter(
({ resource, action }) => !(resource === "insights" && action === "read")
);
const customRole = await prisma.role.create({
data: {
id: `e2e_no_insights_${orgId}_${Date.now()}`,
name: "E2E Role Without Insights",
description: "E2E role for testing - has all permissions except insights.read",
color: "#dc2626",
teamId: orgId,
type: "CUSTOM",
permissions: {
create: permissions,
},
},
});
await prisma.membership.update({
where: {
userId_teamId: {
userId: userOne.id,
teamId: teamOne.id,
},
},
data: {
customRoleId: customRole.id,
},
});
const featuresRepository = new FeaturesRepository(prisma);
const isPBACEnabled = await featuresRepository.checkIfTeamHasFeature(orgId, "pbac");
expect(isPBACEnabled).toBe(true);
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: userOne.id,
teamId: teamOne.id,
permission: "insights.read",
fallbackRoles: [],
});
expect(hasPermission).toBe(false);
await userOne.apiLogin();
await page.goto("/insights");
// Verify the user can access the insights page
await page.locator('[data-testid^="insights-filters-"]').waitFor();
expect(page.url()).toContain("/insights");
});
});
@@ -0,0 +1,29 @@
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { prisma } from "@calcom/prisma";
// Create array of all permissions from PERMISSION_REGISTRY
export const createAllPermissionsArray = () => {
const allPermissions: { resource: string; action: string }[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, resourceConfig]) => {
if (resource === "*") {
return;
}
Object.entries(resourceConfig).forEach(([action, _details]) => {
allPermissions.push({ resource, action });
});
});
return allPermissions;
};
export const enablePBACForTeam = async (teamId: number) => {
await prisma.teamFeatures.create({
data: {
featureId: "pbac",
teamId: teamId,
assignedBy: "e2e",
assignedAt: new Date(),
},
});
};
+2 -2
View File
@@ -775,10 +775,10 @@ test.describe("Out of office", () => {
//Default filter 'Last 7 Days' when DateRange Filter is selected
await test.step("Default filter - 'Last 7 Days'", async () => {
await addFilter(page, "dateRange");
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await addFilter(page, "dateRange");
await entriesListRespPromise;
//1 OOO record should be visible for member3, end=currentDate-4days
@@ -836,10 +836,10 @@ test.describe("Out of office", () => {
//Select 'Last 30 Days'
await test.step("select 'Last 30 Days'", async () => {
await addFilter(page, "dateRange");
const entriesListRespPromise1 = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await addFilter(page, "dateRange");
await entriesListRespPromise1;
const entriesListRespPromise2 = page.waitForResponse(
+1 -1
View File
@@ -15,7 +15,7 @@ test.describe("Workflow Tab - Event Type", () => {
test("Creating a new workflow", async ({ workflowPage }) => {
const { createWorkflow, assertListCount } = workflowPage;
await createWorkflow({ name: "" });
await createWorkflow({ name: "test workflow" });
await assertListCount(3);
});
+7
View File
@@ -512,6 +512,10 @@
<symbol class="lucide lucide-paperclip" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="paperclip">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" ></path>
</symbol>
<symbol class="lucide lucide-pause" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="pause">
<rect x="14" y="4" width="4" height="16" rx="1" ></rect>
<rect x="6" y="4" width="4" height="16" rx="1" ></rect>
</symbol>
<symbol class="lucide lucide-pencil" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="pencil">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" ></path>
<path d="m15 5 4 4" ></path>
@@ -533,6 +537,9 @@
<symbol class="lucide lucide-phone" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
</symbol>
<symbol class="lucide lucide-play" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="play">
<polygon points="6 3 20 12 6 21 6 3" ></polygon>
</symbol>
<symbol class="lucide lucide-plus" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="plus">
<path d="M5 12h14" ></path>
<path d="M12 5v14" ></path>

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+2 -1
View File
@@ -16,7 +16,8 @@
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display_override": ["minimal-ui"],
"display": "standalone",
"scope": "/",
"start_url": "/"
}
}
+85 -6
View File
@@ -23,7 +23,7 @@
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم",
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
"verify_email_button": "تأكيد البريد الإلكتروني",
"cal_ai_assistant": "مساعد Cal AI",
"cal_ai_assistant": "المساعد",
"send_cal_video_transcription_emails": "إرسال رسائل النسخ النصي لفيديو Cal",
"description_send_cal_video_transcription_emails": "إرسال رسائل بريد إلكتروني تحتوي على النسخ النصي لفيديو Cal بعد انتهاء الاجتماع. (يتطلب خطة مدفوعة)",
"verify_email_change_description": "لقد طلبت تغيير البريد الإلكتروني المستخدم لحسابك في {{appName}}. الرجاء النقر على الزر أدناه لتأكيد بريدك الإلكتروني الجديد.",
@@ -819,6 +819,8 @@
"workflow_validation_failed": "فشل التحقق من صحة سير العمل",
"workflow_validation_empty_fields": "خطوة واحدة أو أكثر من خطوات سير العمل تحتوي على محتوى رسالة فارغ",
"workflow_validation_unverified_contacts": "لم يتم التحقق من رقم هاتف واحد أو أكثر أو عناوين البريد الإلكتروني",
"supercharge_your_workflows_with_cal_ai": "عزز سير عملك مع Cal.ai",
"supercharge_your_workflows_with_cal_ai_description": "وكلاء ذكاء اصطناعي واقعيون يقومون بحجز الاجتماعات وإرسال التذكيرات ومتابعة عملائك.",
"phone_number_imported_successfully": "تم استيراد رقم الهاتف وربطه بالوكيل بنجاح",
"phone_number_deleted_successfully": "تم حذف رقم الهاتف بنجاح",
"delete_phone_number_confirmation": "هل أنت متأكد أنك تريد حذف رقم الهاتف هذا؟ لا يمكن التراجع عن هذا الإجراء.",
@@ -846,6 +848,7 @@
"phone_number_subscription_cancelled_successfully": "تم إلغاء اشتراك رقم الهاتف بنجاح",
"updating": "جاري التحديث",
"round_robin": "الترتيب الدوري",
"hi_how_are_you_doing": "مرحبًا، كيف حالك؟",
"round_robin_description": "نقل الاجتماعات بشكل دوري بين أعضاء الفريق المتعددين.",
"managed_event": "حدث تم إدارته",
"username_placeholder": "اسم المستخدم",
@@ -876,9 +879,9 @@
"are_you_sure_you_want_to_delete_workflow_step": "هل أنت متأكد من أنك تريد حذف خطوة مسار العمل هذه؟",
"do_you_still_want_to_unsubscribe": "هل ما زلت ترغب في إلغاء اشتراك رقم الهاتف من هذا الوكيل؟",
"the_action_will_disconnect_phone_number": "سيؤدي هذا الإجراء إلى فصل رقم الهاتف عن الوكيل. لن يتمكن الوكيل من إجراء مكالمات حتى يتم توصيل رقم هاتف جديد.",
"cal_ai_phone_numbers": "أرقام هواتف Cal AI",
"cal_ai_phone_numbers": "أرقام الهواتف",
"connect_phone_number": "توصيل رقم الهاتف",
"cal_ai_phone_numbers_description": "إدارة أرقام هواتف Cal AI الخاصة بك",
"cal_ai_phone_numbers_description": "إدارة أرقام هواتفك",
"import_number": "استيراد رقم",
"this_action_will_also": "سيقوم هذا الإجراء أيضًا بـ:",
"import_phone_number": "استيراد رقم الهاتف",
@@ -886,7 +889,7 @@
"cancel_your_phone_number_subscription": "إلغاء اشتراك رقم هاتفك",
"delete_associated_phone_number": "حذف رقم الهاتف المرتبط",
"unauthorized_create_workflow": "أنت غير مصرح لك بإنشاء مسار العمل هذا",
"import_phone_number_description": "استيراد رقم هاتف Twilio الخاص بك لاستخدامه مع هاتف Cal AI",
"import_phone_number_description": "استيراد رقم هاتف Twilio الخاص بك لاستخدامه مع الهاتف",
"phone_number_cost": "${{price}}/شهريًا",
"buy_new_number": "شراء رقم جديد",
"buy_number_cost_x_per_month": "تكلفة شراء رقم هاتف هي ${{priceInDollars}} شهريًا. سيتم محاسبتك شهريًا على كل رقم هاتف نشط.",
@@ -897,6 +900,7 @@
"yes_cancel_subscription": "نعم، إلغاء الاشتراك",
"cancel_phone_number_subscription_confirmation": "هل أنت متأكد من رغبتك في إلغاء اشتراك رقم الهاتف هذا؟ لا يمكن التراجع عن هذا الإجراء وستفقد إمكانية الوصول إلى رقم الهاتف هذا.",
"add_members": "إضافة أعضاء...",
"add_members_no_ellipsis": "إضافة أعضاء",
"no_assigned_members": "لا يوجد أعضاء معينين",
"assigned_to": "تم التعيين إلى",
"you_must_be_logged_in_to": "يجب تسجيل الدخول إلى {{url}}",
@@ -1207,6 +1211,7 @@
"categories": "الفئات",
"pricing": "التسعير",
"learn_more": "معرفة المزيد",
"try_now": "جرب الآن",
"privacy_policy": "سياسة الخصوصية",
"terms_of_service": "شروط الخدمة",
"remove": "إزالة",
@@ -1440,6 +1445,7 @@
"whatsapp_attendee_action": "إرسال رسالة عبر WhatsApp إلى أحد الحاضرين",
"workflows": "سير العمل",
"new_workflow_btn": "سير عمل جديد",
"how_would_you_like_to_start": "كيف ترغب في البدء؟",
"add_new_workflow": "إضافة سير عمل جديد",
"reschedule_event_trigger": "متى يتم إعادة جدولة الحدث",
"trigger": "مشغل",
@@ -1716,6 +1722,8 @@
"event_duration_info": "مدة الحدث",
"event_time_info": "وقت بدء الحدث",
"event_type_not_found": "لم يتم العثور على نوع الحدث",
"number_to_call_variable": "رقم للاتصال",
"number_to_call_info": "رقم هاتف المستخدم الذي تتصل به",
"location_variable": "الموقع",
"location_info": "موقع الحدث",
"additional_notes_variable": "ملاحظات إضافية",
@@ -1753,6 +1761,7 @@
"team_url": "رابط الفريق",
"team_members": "أعضاء الفريق",
"more": "المزيد",
"cal_ai_workflows": "سير عمل Cal.ai",
"and_count_more": "و {{count}} أكثر",
"more_page_footer": "نعتبر تطبيق الهاتف المحمول امتداداً لتطبيق الويب، لكن يرجى العودة لتطبيق الويب إذا كنت تقوم بأي إجراءات معقدة.",
"workflow_example_1": "إرسال رسائل قصيرة للحضور للتذكير قبل 24 ساعة من بدء الحدث",
@@ -1761,6 +1770,18 @@
"workflow_example_4": "إرسال رسائل قصيرة للحضور للتذكير قبل ساعة من بدء الحدث",
"workflow_example_5": "إرسال رسالة قصيرة مخصصة للحضور عند إعادة جدولة الحدث",
"workflow_example_6": "إرسال بريد إلكتروني مخصص للمضيف عندما يتم حجز حدث جديد",
"send_sms_reminder": "إرسال تذكير عبر الرسائل القصيرة",
"send_sms_reminder_description": "قبل 24 ساعة من بدء الحدث",
"follow_up_with_no_shows": "متابعة المتغيبين",
"follow_up_with_no_shows_description": "بعد 30 دقيقة من انتهاء الحدث",
"remind_attendees_to_bring_id": "تذكير الحاضرين بإحضار الهوية",
"remind_attendees_to_bring_id_description": "قبل يوم واحد من بدء الحدث",
"email_to_remind_booking": "تذكير بالبريد الإلكتروني",
"email_to_remind_booking_description": "قبل ساعة واحدة من بدء الحدث",
"custom_sms_reminder": "تذكير مخصص عبر الرسائل القصيرة",
"custom_sms_reminder_description": "عند جدولة الحدث",
"custom_email_reminder": "تذكير مخصص بالبريد الإلكتروني",
"custom_email_reminder_description": "إعادة جدولة الحدث للمضيف",
"count_managed_to_limit": "تضمين عدد الحجوزات من أنواع الفعاليات المُدارة",
"welcome_to_cal_header": "مرحبًا بك في {{appName}}!",
"edit_form_later_subtitle": "ستتمكن من تعديل هذا لاحقًا.",
@@ -2184,6 +2205,8 @@
"show_on_booking_page": "إظهار في صفحة الحجز",
"visit_cancelled_booking": "يمكنك زيارة صفحة الحجز الملغى",
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
"standard_templates": "القوالب القياسية",
"cal_ai_templates": "قوالب Cal.ai",
"team_is_unpublished": "لم يُنشر {{team}}",
"org_is_unpublished_description": "رابط هذه المنظمة غير متاح حاليًا. يرجى الاتصال بمالك المنظمة أو طلب النشر منه.",
"team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.",
@@ -2341,7 +2364,6 @@
"could_not_charge_card": "تعذر تنفيذ عملية الدفع من البطاقة.",
"insights": "رؤى",
"routing_forms": "نماذج التوجيه",
"testing_workflow_info_message": "عند اختبار سير العمل هذا، يجب أن تدرك أنه لا يمكن جدولة رسائل البريد الإلكتروني والرسائل القصيرة إلا قبل ساعة واحدة على الأقل",
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
"days": "أيام",
@@ -2464,7 +2486,15 @@
"insights_team_filter": "الفريق: {{teamName}}",
"insights_user_filter": "المستخدم: {{userName}}",
"insights_subtitle": "عرض Insights الحجز عبر أحداثك",
"call_history": "سجل المكالمات",
"call_history_subtitle": "عرض سجل المكالمات عبر مكالمات Cal.ai الخاصة بك",
"location_options": "خيارات الموقع {{locationCount}}",
"channel_type": "نوع القناة",
"end_reason": "سبب الإنهاء",
"session_status": "حالة الجلسة",
"user_sentiment": "شعور المستخدم",
"time_header": "الوقت",
"from_header": "من",
"custom_plan": "الخطة المخصصة",
"email_embed": "البريد الإلكتروني المضمن",
"add_times_to_your_email": "حدّد بعض الفترات المتاحة وأدرجها في رسالتك الإلكترونية",
@@ -2752,6 +2782,8 @@
"account_already_linked": "الحساب مرتبط بالفعل",
"send_email": "إرسال بريد إلكتروني",
"cal_ai_phone_call_action": "الاتصال بالحاضر باستخدام وكيل Cal.ai الصوتي",
"call_to_confirm_booking": "اتصال لتأكيد الحجز",
"cal_ai_phone_call_action_description": "قبل ساعتين من بدء الحدث",
"cal_ai_agent_configuration": "إعدادات وكيل Cal.ai",
"choose_at_least_one_event_type_test_call": "يرجى اختيار نوع حدث واحد على الأقل لإجراء مكالمة اختبارية.",
"mark_as_no_show": "وضع علامة عدم الحضور",
@@ -3203,9 +3235,15 @@
"verify_email_change": "تأكيد تغيير البريد الإلكتروني",
"buy_credits": "شراء رصيد",
"credits": "الرصيد",
"view_and_manage_credits": "عرض وإدارة الرصيد",
"credits_used": "الرصيد المستخدم",
"total_credits_remaining": "الرصيد المتبقي الإجمالي",
"credits_per_tip_org": "تحصل على 1000 رصيد شهريًا لكل عضو في الفريق",
"credits_per_tip_teams": "تحصل على 750 رصيد شهريًا لكل عضو في الفريق",
"view_and_manage_credits": "عرض وإدارة الرصيد لإرسال رسائل SMS",
"view_and_manage_credits_description": "عرض وإدارة الرصيد لإرسال رسائل SMS. قيمة الرصيد الواحد هي 1¢ (دولار أمريكي). <0>معرفة المزيد</0>",
"credit_worth_description": "الرصيد الواحد يساوي 1 سنت (دولار أمريكي). <0>تعرف على المزيد</0>",
"buy_additional_credits": "شراء رصيد إضافي (0.01 دولار لكل رصيد)",
"view_additional_credits_expense_tip": "يمكنك عرض إنفاق الرصيد الإضافي في سجل النفقات الخاص بك",
"overview": "نظرة عامة",
"organization_slug_taken": "الاسم المختصر للمؤسسة مستخدم بالفعل",
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "لا يمكنك إنشاء مؤسسة لأنك بالفعل جزء من مؤسسة",
@@ -3386,10 +3424,13 @@
"credit_limit_reached_message": "لقد نفد رصيد فريق Cal.com الخاص بك {{teamName}}. نتيجة لذلك، يتم الآن إرسال رسائل SMS عبر البريد الإلكتروني بدلاً من ذلك. لاستئناف إرسال SMS، يرجى شراء رصيد إضافي.",
"credit_limit_reached_message_user": "لقد نفدت رصيد حسابك على Cal.com. نتيجة لذلك، يتم الآن إرسال رسائل SMS عبر البريد الإلكتروني بدلاً من ذلك. لاستئناف إرسال رسائل SMS، يرجى شراء رصيد إضافي.",
"current_credit_balance": "الرصيد الحالي: {{balance}} رصيد",
"current_balance": "الرصيد الحالي:",
"notification_about_your_booking": "إشعار بخصوص حجزك",
"monthly_credits": "الرصيد الشهري",
"total_credits": "إجمالي الرصيد: {{totalCredits}}",
"remaining_credits": "الرصيد المتبقي: {{remainingCredits}}",
"remaining": "المتبقي",
"total": "الإجمالي",
"additional_credits": "رصيد إضافي",
"routing_form_next_in_queue": "{{count}} التالي في قائمة الانتظار",
"routing_form_select_members_to_email": "إرسال ردود البريد الإلكتروني إلى",
@@ -3467,6 +3508,11 @@
"pbac_desc_view_workflows": "عرض سير العمل الحالي وإعداداته",
"pbac_desc_update_workflows": "تعديل وتغيير إعدادات سير العمل",
"pbac_desc_delete_workflows": "إزالة سير العمل من النظام",
"pbac_resource_webhook": "ويب هوك",
"pbac_desc_create_webhooks": "إنشاء ويب هوكس",
"pbac_desc_view_webhooks": "عرض ويب هوكس",
"pbac_desc_update_webhooks": "تحديث الويب هوك",
"pbac_desc_delete_webhooks": "حذف الويب هوك",
"pbac_desc_manage_workflows": "وصول كامل لإدارة جميع سير العمل",
"pbac_desc_create_event_types": "إنشاء أنواع الأحداث",
"pbac_desc_view_event_types": "عرض أنواع الأحداث",
@@ -3594,6 +3640,7 @@
"webhook_trigger_event": "اسم حدث المشغل (مثل BOOKING_CREATED، BOOKING_CANCELLED)",
"webhook_created_at": "وقت إنشاء الويب هوك",
"webhook_type": "رابط نوع الحدث",
"set_up_agent": "إعداد الوكيل",
"webhook_title": "اسم نوع الحدث",
"webhook_start_time": "وقت بدء الحدث",
"webhook_end_time": "وقت انتهاء الحدث",
@@ -3625,6 +3672,9 @@
"visit": "زيارة",
"location_custom_label_input_label": "تسمية مخصصة على صفحة الحجز",
"meeting_link": "رابط الاجتماع",
"session_outcome": "نتيجة الجلسة",
"call_created": "تم إنشاء المكالمة",
"voicemail": "البريد الصوتي",
"my_bookings": "حجوزاتي",
"phone": "الهاتف",
"free": "مجاني",
@@ -3632,6 +3682,8 @@
"user_name": "اسم المستخدم",
"expand_panel": "توسيع اللوحة",
"collapse_panel": "طي اللوحة",
"email_verification_required": "التحقق من البريد الإلكتروني مطلوب لهذا النوع من الأحداث",
"invalid_verification_code": "تم تقديم رمز تحقق غير صالح",
"you_have_one_team": "لديك فريق واحد",
"consider_consolidating_one_team_org": "فكر في إنشاء مؤسسة لتوحيد الفواتير وأدوات الإدارة والتحليلات عبر فريقك.",
"consider_consolidating_multi_team_org": "فكر في إنشاء مؤسسة لتوحيد الفواتير وأدوات الإدارة والتحليلات عبر فرقك.",
@@ -3652,5 +3704,32 @@
"before_scheduled_start_time": "قبل وقت البدء المحدد",
"cancel_booking_acknowledge_no_show_fee": "أقر بأنه عند إلغاء الحجز قبل {{timeValue}} {{timeUnit}} من وقت البدء سيتم فرض رسوم التخلف عن الحضور بقيمة {{amount, currency}}",
"contact_organizer": "إذا كانت لديك أي أسئلة، يرجى التواصل مع المنظم.",
"booking_time_option": "وقت الحجز",
"booking_time_option_description": "عندما يتم جدولة الحجز (من البداية إلى النهاية)",
"created_at_option": "تم الإنشاء في",
"created_at_option_description": "عندما تم إنشاء الحجز في الأصل",
"call_details": "تفاصيل المكالمة",
"call_id": "معرف المكالمة",
"call_information": "معلومات المكالمة",
"sentiment": "المشاعر",
"disconnect_reason": "سبب الانقطاع",
"call_summary": "ملخص المكالمة",
"transcription": "النسخ",
"event_details": "تفاصيل الحدث",
"agent": "الوكيل",
"no_transcript_available": "لا يوجد نص متاح",
"testing_sms_workflow_info_message": "عند اختبار سير العمل هذا، انتبه إلى أنه يجب جدولة الرسائل النصية القصيرة قبل 15 دقيقة على الأقل",
"start_from_scratch_title": "البدء من الصفر",
"start_from_scratch_description": "إنشاء سير العمل الخاص بك من الصفر.",
"cal_ai_template_title": "قالب Cal.ai",
"cal_ai_template_description": "وكلاء الذكاء الاصطناعي الذين يحجزون الاجتماعات، ويرسلون التذكيرات، ويتابعون!",
"voice": "الصوت",
"select_voice": "اختر الصوت",
"select_voice_for_agent": "اختر صوتًا لوكيلك",
"choose_a_voice_for_your_agent": "اختر صوتًا لوكيلك",
"trait": "سمة",
"voice_id": "معرّف الصوت",
"use_voice": "استخدم الصوت",
"current_voice": "الصوت الحالي",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+85 -6
View File
@@ -23,7 +23,7 @@
"verify_email_banner_body": "Ən yaxşı e-poçt və təqvim çatdırılmasını təmin etmək üçün e-poçt ünvanınızı təsdiqləyin",
"verify_email_email_header": "E-poçt ünvanınızı təsdiqləyin",
"verify_email_button": "E-poçtu təsdiqlə",
"cal_ai_assistant": "Cal AI Köməkçisi",
"cal_ai_assistant": "Köməkçi",
"send_cal_video_transcription_emails": "Cal Video transkript e-poçtlarını göndər",
"description_send_cal_video_transcription_emails": "Görüş bitdikdən sonra Cal Video transkripsiyası olan e-poçtları göndərin. (Ödənişli plan tələb olunur)",
"verify_email_change_description": "Son zamanlarda {{appName}} hesabınıza daxil olmaq üçün istifadə etdiyiniz e-poçt ünvanını dəyişdirmək istədiyinizi bildirdiniz. Yeni e-poçt ünvanınızı təsdiqləmək üçün aşağıdakı düyməni basın.",
@@ -819,6 +819,8 @@
"workflow_validation_failed": "İş axını doğrulaması uğursuz oldu",
"workflow_validation_empty_fields": "Bir və ya daha çox iş axını addımında boş mesaj məzmunu var",
"workflow_validation_unverified_contacts": "Bir və ya daha çox telefon nömrəsi və ya e-poçt ünvanı təsdiqlənməyib",
"supercharge_your_workflows_with_cal_ai": "Cal.ai ilə İş Axınlarınızı Gücləndirin",
"supercharge_your_workflows_with_cal_ai_description": "Görüşləri təyin edən, xatırlatmalar göndərən və müştərilərinizlə əlaqə saxlayan canlı AI agentləri.",
"phone_number_imported_successfully": "Telefon nömrəsi uğurla idxal edildi və agentə bağlandı",
"phone_number_deleted_successfully": "Telefon nömrəsi uğurla silindi",
"delete_phone_number_confirmation": "Bu telefon nömrəsini silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
@@ -846,6 +848,7 @@
"phone_number_subscription_cancelled_successfully": "Telefon nömrəsi abunəliyi uğurla ləğv edildi",
"updating": "Yenilənir",
"round_robin": "Round Robin",
"hi_how_are_you_doing": "Salam, necəsən?",
"round_robin_description": "Görüşləri bir neçə komanda üzvü arasında dövr edin.",
"managed_event": "İdarə olunan Tədbir",
"username_placeholder": "istifadəçi adı",
@@ -876,9 +879,9 @@
"are_you_sure_you_want_to_delete_workflow_step": "Bu iş axını addımını silmək istədiyinizə əminsiniz?",
"do_you_still_want_to_unsubscribe": "Bu agentdən telefon nömrəsinin abunəliyini ləğv etmək istəyirsiniz?",
"the_action_will_disconnect_phone_number": "Bu əməliyyat telefon nömrəsini agentdən ayıracaq. Yeni telefon nömrəsi qoşulana qədər agent zəng edə bilməyəcək.",
"cal_ai_phone_numbers": "Cal AI Telefon Nömrələri",
"cal_ai_phone_numbers": "Telefon Nömrələri",
"connect_phone_number": "Telefon Nömrəsini Qoş",
"cal_ai_phone_numbers_description": "Cal AI Telefon Nömrələrinizi idarə edin",
"cal_ai_phone_numbers_description": "Telefon Nömrələrinizi İdarə edin",
"import_number": "Nömrəni İdxal et",
"this_action_will_also": "Bu əməliyyat həmçinin:",
"import_phone_number": "Telefon Nömrəsini İdxal et",
@@ -886,7 +889,7 @@
"cancel_your_phone_number_subscription": "Telefon nömrəsi abunəliyinizi ləğv edin",
"delete_associated_phone_number": "Əlaqəli telefon nömrəsini silin",
"unauthorized_create_workflow": "Bu iş axınını yaratmaq üçün səlahiyyətiniz yoxdur",
"import_phone_number_description": "Cal AI Phone ilə istifadə etmək üçün Twilio telefon nömrənizi idxal edin",
"import_phone_number_description": "Telefon ilə istifadə etmək üçün Twilio telefon nömrənizi idxal edin",
"phone_number_cost": "${{price}}/ay",
"buy_new_number": "Yeni Nömrə Alın",
"buy_number_cost_x_per_month": "Telefon nömrəsi almaq ayda ${{priceInDollars}} məbləğində başa gəlir. Hər aktiv telefon nömrəsi üçün aylıq ödəniş edəcəksiniz.",
@@ -897,6 +900,7 @@
"yes_cancel_subscription": "Bəli, Abunəliyi Ləğv Et",
"cancel_phone_number_subscription_confirmation": "Bu telefon nömrəsi abunəliyini ləğv etmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz və bu telefon nömrəsinə girişinizi itirəcəksiniz.",
"add_members": "Üzvlər əlavə et...",
"add_members_no_ellipsis": "Üzvləri əlavə et",
"no_assigned_members": "Təyin edilmiş üzvlər yoxdur",
"assigned_to": "Təyin edilmiş",
"you_must_be_logged_in_to": "{{url}}-ə daxil olmalısınız",
@@ -1207,6 +1211,7 @@
"categories": "Kateqoriyalar",
"pricing": "Qiymətlər",
"learn_more": "Daha çox öyrən",
"try_now": "İndi cəhd edin",
"privacy_policy": "Məxfilik Siyasəti",
"terms_of_service": "Xidmət Şərtləri",
"remove": "Sil",
@@ -1440,6 +1445,7 @@
"whatsapp_attendee_action": "iştirakçıya WhatsApp mesajı göndərin",
"workflows": "İş axınları",
"new_workflow_btn": "Yeni İş Axını",
"how_would_you_like_to_start": "Necə başlamaq istəyirsiniz?",
"add_new_workflow": "Yeni iş axını əlavə et",
"reschedule_event_trigger": "hadisə yenidən planlaşdırıldıqda",
"trigger": "Tətik",
@@ -1716,6 +1722,8 @@
"event_duration_info": "Tədbirin müddəti",
"event_time_info": "Tədbirin başlama vaxtı",
"event_type_not_found": "EventType tapılmadı",
"number_to_call_variable": "Zəng ediləcək nömrə",
"number_to_call_info": "Zəng etdiyiniz istifadəçinin telefon nömrəsi",
"location_variable": "Məkan",
"location_info": "Tədbirin yeri",
"additional_notes_variable": "Əlavə qeydlər",
@@ -1753,6 +1761,7 @@
"team_url": "Komanda URL",
"team_members": "Komanda üzvləri",
"more": "Daha çox",
"cal_ai_workflows": "Cal.ai İş Axınları",
"and_count_more": "və {{count}} daha",
"more_page_footer": "Mobil tətbiqi veb tətbiqinin bir uzantısı kimi görürük. Hər hansı mürəkkəb əməliyyatları yerinə yetirirsinizsə, lütfən, veb tətbiqinə qayıdın.",
"workflow_example_1": "Tədbir başlamazdan 24 saat əvvəl iştirakçıya SMS xatırlatma göndərin",
@@ -1761,6 +1770,18 @@
"workflow_example_4": "Tədbir başlamazdan 1 saat əvvəl iştirakçıya e-poçt xatırlatma göndərin",
"workflow_example_5": "Tədbir təxirə salındıqda ev sahibinə xüsusi e-poçt göndərin",
"workflow_example_6": "Yeni tədbir sifariş edildikdə ev sahibinə xüsusi SMS göndərin",
"send_sms_reminder": "SMS xatırlatması göndər",
"send_sms_reminder_description": "Tədbir başlamazdan 24 saat əvvəl",
"follow_up_with_no_shows": "Gəlməyənlərlə əlaqə saxla",
"follow_up_with_no_shows_description": "Tədbir bitdikdən 30 dəqiqə sonra",
"remind_attendees_to_bring_id": "İştirakçılara şəxsiyyət vəsiqəsi gətirməyi xatırlat",
"remind_attendees_to_bring_id_description": "Tədbir başlamazdan 1 gün əvvəl",
"email_to_remind_booking": "E-poçt Xatırlatması",
"email_to_remind_booking_description": "Tədbir başlamazdan 1 saat əvvəl",
"custom_sms_reminder": "Fərdi SMS Xatırlatması",
"custom_sms_reminder_description": "Tədbir planlaşdırıldıqda",
"custom_email_reminder": "Fərdi E-poçt Xatırlatması",
"custom_email_reminder_description": "Tədbir ev sahibi üçün yenidən planlaşdırıldıqda",
"count_managed_to_limit": "İdarə olunan tədbir növlərindən rezervasiya saylarını daxil edin",
"welcome_to_cal_header": "{{appName}}-a xoş gəlmisiniz!",
"edit_form_later_subtitle": "Bunu sonra redaktə edə biləcəksiniz.",
@@ -2184,6 +2205,8 @@
"show_on_booking_page": "Rezervasiya səhifəsində göstər",
"visit_cancelled_booking": "Ləğv edilmiş rezervasiya səhifəsinə baxa bilərsiniz",
"get_started_zapier_templates": "Zapier şablonları ilə başlayın",
"standard_templates": "Standart Şablonlar",
"cal_ai_templates": "Cal.ai Şablonları",
"team_is_unpublished": "{{team}} yayımlanmayıb",
"org_is_unpublished_description": "Bu təşkilat bağlantısı hazırda mövcud deyil. Zəhmət olmasa təşkilat sahibinə müraciət edin və ya onu yayımlamasını xahiş edin.",
"team_is_unpublished_description": "Bu komanda bağlantısı hazırda mövcud deyil. Zəhmət olmasa komanda sahibinə müraciət edin və ya onu yayımlamasını xahiş edin.",
@@ -2341,7 +2364,6 @@
"could_not_charge_card": "Ödəniş üçün kartdan pul çıxıla bilmədi.",
"insights": "Görüşlər",
"routing_forms": "Marşrutlaşdırma Formları",
"testing_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, e-poçtların və SMS-lərin ən azı 1 saat əvvəl planlaşdırıla biləcəyini unutmayın",
"insights_no_data_found_for_filter": "Seçilmiş filtr və ya tarixlər üçün məlumat tapılmadı.",
"acknowledge_booking_no_show_fee": "Bu tədbirə qatılmasam, kartıma {{amount, currency}} gəlməmə haqqı tətbiq olunacağını qəbul edirəm.",
"days": "günlər",
@@ -2464,7 +2486,15 @@
"insights_team_filter": "Komanda: {{teamName}}",
"insights_user_filter": "İstifadəçi: {{userName}}",
"insights_subtitle": "Tədbirləriniz üzrə rezervasiya məlumatlarını görün",
"call_history": "Zəng Tarixçəsi",
"call_history_subtitle": "Cal.ai zəngləriniz üzrə zəng tarixçəsinə baxın",
"location_options": "{{locationCount}} məkan seçimi",
"channel_type": "Kanal Növü",
"end_reason": "Bitmə Səbəbi",
"session_status": "Sessiya Statusu",
"user_sentiment": "İstifadəçi Münasibəti",
"time_header": "Vaxt",
"from_header": "Kimdən",
"custom_plan": "Xüsusi Plan",
"email_embed": "Email Əlavəsi",
"add_times_to_your_email": "Mövcud vaxtları seçin və Email-ə əlavə edin",
@@ -2752,6 +2782,8 @@
"account_already_linked": "Hesab artıq əlaqələndirilib",
"send_email": "E-poçt göndərin",
"cal_ai_phone_call_action": "Cal.ai Səs Agenti vasitəsilə iştirakçıya zəng edin",
"call_to_confirm_booking": "Rezervasiyanı təsdiqləmək üçün zəng edin",
"cal_ai_phone_call_action_description": "Tədbir başlamazdan 2 saat əvvəl",
"cal_ai_agent_configuration": "Cal.ai Agent Konfiqurasiyası",
"choose_at_least_one_event_type_test_call": "Test zəngi etmək üçün ən azı bir tədbir növü seçin.",
"mark_as_no_show": "Gəlməyən kimi işarələyin",
@@ -3203,9 +3235,15 @@
"verify_email_change": "E-poçt dəyişikliyini təsdiqlə",
"buy_credits": "Kredit alın",
"credits": "Kreditlər",
"view_and_manage_credits": "Kreditləri görüntüləyin və idarə edin",
"credits_used": "İstifadə edilmiş kreditlər",
"total_credits_remaining": "Ümumi qalan",
"credits_per_tip_org": "Hər ay, hər komanda üzvü üçün 1000 kredit əldə edirsiniz",
"credits_per_tip_teams": "Hər ay, hər komanda üzvü üçün 750 kredit əldə edirsiniz",
"view_and_manage_credits": "SMS mesajları göndərmək üçün kreditlərə baxın və idarə edin",
"view_and_manage_credits_description": "SMS mesajları göndərmək üçün kreditləri görüntüləyin və idarə edin. Bir kredit 1¢ (USD) dəyərindədir. <0>Daha ətraflı</0>",
"credit_worth_description": "Bir kredit 1¢ (USD) dəyərindədir. <0>Daha ətraflı öyrənin</0>",
"buy_additional_credits": "Əlavə kreditlər alın (kredit başına $0.01)",
"view_additional_credits_expense_tip": "Əlavə kredit xərclərini xərc jurnalınızda görə bilərsiniz",
"overview": "Ümumi baxış",
"organization_slug_taken": "Təşkilat slaqı artıq istifadə olunub",
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "Siz artıq bir təşkilatın üzvü olduğunuz üçün yeni təşkilat yarada bilməzsiniz",
@@ -3386,10 +3424,13 @@
"credit_limit_reached_message": "Cal.com {{teamName}} komandanızın kreditləri bitdi. Nəticədə, SMS mesajları indi e-poçt vasitəsilə göndərilir. SMS göndərməyə davam etmək üçün, zəhmət olmasa əlavə kreditlər alın.",
"credit_limit_reached_message_user": "Cal.com hesabınızın kreditləri bitib. Nəticə olaraq, SMS mesajları artıq e-poçt vasitəsilə göndərilir. SMS göndərməyə davam etmək üçün, zəhmət olmasa əlavə kreditlər alın.",
"current_credit_balance": "Cari balans: {{balance}} kredit",
"current_balance": "Cari balans:",
"notification_about_your_booking": "Rezervasiyanız haqqında bildiriş",
"monthly_credits": "Aylıq kreditlər",
"total_credits": "Ümumi kreditlər: {{totalCredits}}",
"remaining_credits": "Qalan kreditlər: {{remainingCredits}}",
"remaining": "Qalan",
"total": "Ümumi",
"additional_credits": "Əlavə kreditlər",
"routing_form_next_in_queue": "Növbədə {{count}} növbəti",
"routing_form_select_members_to_email": "E-poçt cavablarını göndərmək üçün üzvləri seçin",
@@ -3467,6 +3508,11 @@
"pbac_desc_view_workflows": "Mövcud iş axınlarına və onların konfiqurasiyalarına baxış",
"pbac_desc_update_workflows": "İş axını parametrlərini redaktə etmək və dəyişdirmək",
"pbac_desc_delete_workflows": "İş axınlarını sistemdən silmək",
"pbac_resource_webhook": "Webhook",
"pbac_desc_create_webhooks": "Webhook yaradın",
"pbac_desc_view_webhooks": "Webhook-lara baxın",
"pbac_desc_update_webhooks": "Webhook-ləri yeniləyin",
"pbac_desc_delete_webhooks": "Webhook-ləri silin",
"pbac_desc_manage_workflows": "Bütün iş axınlarına tam idarəetmə girişi",
"pbac_desc_create_event_types": "Tədbir növləri yaratmaq",
"pbac_desc_view_event_types": "Tədbir növlərinə baxmaq",
@@ -3594,6 +3640,7 @@
"webhook_trigger_event": "Tətikləyici hadisənin adı (məs., BOOKING_CREATED, BOOKING_CANCELLED)",
"webhook_created_at": "Webhook yaradılma vaxtı",
"webhook_type": "Tədbir növünün slaqı",
"set_up_agent": "Agent qurun",
"webhook_title": "Tədbir növünün adı",
"webhook_start_time": "Tədbirin başlama vaxtı",
"webhook_end_time": "Tədbirin bitmə vaxtı",
@@ -3625,6 +3672,9 @@
"visit": "Ziyarət et",
"location_custom_label_input_label": "Rezervasiya səhifəsində xüsusi etiket",
"meeting_link": "Görüş linki",
"session_outcome": "Sessiya nəticəsi",
"call_created": "Zəng yaradıldı",
"voicemail": "Səsli poçt",
"my_bookings": "Mənim Rezervasiyalarım",
"phone": "Telefon",
"free": "Pulsuz",
@@ -3632,6 +3682,8 @@
"user_name": "İstifadəçi Adı",
"expand_panel": "Paneli Genişlət",
"collapse_panel": "Paneli Yığ",
"email_verification_required": "Bu tədbir növü üçün e-poçt doğrulaması tələb olunur",
"invalid_verification_code": "Yanlış doğrulama kodu təqdim edilib",
"you_have_one_team": "Bir komandanız var",
"consider_consolidating_one_team_org": "Komandanız üzrə ödənişləri, admin alətlərini və analitikanı birləşdirmək üçün təşkilat qurmağı nəzərdən keçirin.",
"consider_consolidating_multi_team_org": "Komandalarınız üzrə ödənişləri, admin alətlərini və analitikanı birləşdirmək üçün təşkilat qurmağı nəzərdən keçirin.",
@@ -3652,5 +3704,32 @@
"before_scheduled_start_time": "Planlaşdırılmış başlama vaxtından əvvəl",
"cancel_booking_acknowledge_no_show_fee": "Təsdiq edirəm ki, rezervasiyanı başlama vaxtından {{timeValue}} {{timeUnit}} ərzində ləğv etməklə mənə {{amount, currency}} məbləğində gəlməmə haqqı tutulacaq",
"contact_organizer": "Hər hansı sualınız varsa, təşkilatçı ilə əlaqə saxlayın.",
"booking_time_option": "Rezervasiya vaxtı",
"booking_time_option_description": "Rezervasiyanın planlaşdırıldığı vaxt (başlanğıcdan sona)",
"created_at_option": "Yaradılma tarixi",
"created_at_option_description": "Rezervasiyanın ilkin yaradıldığı vaxt",
"call_details": "Zəng detalları",
"call_id": "Zəng ID",
"call_information": "Zəng məlumatı",
"sentiment": "Əhval-ruhiyyə",
"disconnect_reason": "Bağlantının kəsilmə səbəbi",
"call_summary": "Zəng xülasəsi",
"transcription": "Transkripsiya",
"event_details": "Tədbir detalları",
"agent": "Agent",
"no_transcript_available": "Transkript mövcud deyil",
"testing_sms_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, SMS-lərin ən azı 15 dəqiqə əvvəlcədən planlaşdırılmalı olduğunu nəzərə alın",
"start_from_scratch_title": "Sıfırdan başlayın",
"start_from_scratch_description": "Öz iş axınınızı sıfırdan yaradın.",
"cal_ai_template_title": "Cal.ai şablonu",
"cal_ai_template_description": "Görüşləri təyin edən, xatırlatmalar göndərən və davam etdirən AI agentləri!",
"voice": "Səs",
"select_voice": "Səs seçin",
"select_voice_for_agent": "Agentiniz üçün səs seçin",
"choose_a_voice_for_your_agent": "Agentiniz üçün səs seçin",
"trait": "Xüsusiyyət",
"voice_id": "Səs ID",
"use_voice": "Səsdən istifadə edin",
"current_voice": "Cari səs",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+85 -6
View File
@@ -23,7 +23,7 @@
"verify_email_banner_body": "Проверете имейл адреса си, за да гарантирате най-добрата доставка на имейли и календари",
"verify_email_email_header": "Потвърдете вашия имейл адрес",
"verify_email_button": "Потвърди имейл",
"cal_ai_assistant": "Cal AI асистент",
"cal_ai_assistant": "Асистент",
"send_cal_video_transcription_emails": "Изпращане на имейли с транскрипция на Cal Video",
"description_send_cal_video_transcription_emails": "Изпращане на имейли с транскрипцията на Cal Video след края на срещата. (Изисква платен план)",
"verify_email_change_description": "Наскоро поискахте да промените имейл адреса, който използвате за вход във вашия {{appName}} акаунт. Моля, кликнете върху бутона по-долу, за да потвърдите новия си имейл адрес.",
@@ -819,6 +819,8 @@
"workflow_validation_failed": "Валидацията на работния процес е неуспешна",
"workflow_validation_empty_fields": "Една или повече стъпки от работния процес имат празно съдържание на съобщението",
"workflow_validation_unverified_contacts": "Един или повече телефонни номера или имейл адреси не са потвърдени",
"supercharge_your_workflows_with_cal_ai": "Подсилете вашите работни процеси с Cal.ai",
"supercharge_your_workflows_with_cal_ai_description": "Реалистични AI агенти, които резервират срещи, изпращат напомняния и проследяват вашите клиенти.",
"phone_number_imported_successfully": "Телефонният номер е импортиран и свързан с агента успешно",
"phone_number_deleted_successfully": "Телефонният номер е изтрит успешно",
"delete_phone_number_confirmation": "Сигурни ли сте, че искате да изтриете този телефонен номер? Това действие не може да бъде отменено.",
@@ -846,6 +848,7 @@
"phone_number_subscription_cancelled_successfully": "Абонаментът за телефонен номер е отменен успешно",
"updating": "Обновяване",
"round_robin": "Round Robin",
"hi_how_are_you_doing": "Здравейте, как сте?",
"round_robin_description": "Разпределяйте срещи между множество членове на екипа.",
"managed_event": "Управлявано събитие",
"username_placeholder": "потребителско име",
@@ -876,9 +879,9 @@
"are_you_sure_you_want_to_delete_workflow_step": "Сигурни ли сте, че искате да изтриете тази стъпка от работния процес?",
"do_you_still_want_to_unsubscribe": "Все още ли искате да прекратите абонамента за този телефонен номер от този агент?",
"the_action_will_disconnect_phone_number": "Това действие ще прекъсне връзката на телефонния номер с агента. Агентът няма да може да осъществява обаждания, докато не бъде свързан нов телефонен номер.",
"cal_ai_phone_numbers": "Cal AI телефонни номера",
"cal_ai_phone_numbers": "Телефонни номера",
"connect_phone_number": "Свързване на телефонен номер",
"cal_ai_phone_numbers_description": "Управлявайте вашите Cal AI телефонни номера",
"cal_ai_phone_numbers_description": "Управлявайте вашите телефонни номера",
"import_number": "Импортиране на номер",
"this_action_will_also": "Това действие също ще:",
"import_phone_number": "Импортиране на телефонен номер",
@@ -886,7 +889,7 @@
"cancel_your_phone_number_subscription": "Отказване на абонамента за телефонен номер",
"delete_associated_phone_number": "Изтриване на свързания телефонен номер",
"unauthorized_create_workflow": "Нямате разрешение да създадете този работен процес",
"import_phone_number_description": "Импортирайте вашия Twilio телефонен номер за използване с Cal AI Phone",
"import_phone_number_description": "Импортирайте вашия Twilio телефонен номер за използване с Phone",
"phone_number_cost": "${{price}}/месец",
"buy_new_number": "Купете нов номер",
"buy_number_cost_x_per_month": "Закупуването на телефонен номер струва ${{priceInDollars}} на месец. Ще бъдете таксувани месечно за всеки активен телефонен номер.",
@@ -897,6 +900,7 @@
"yes_cancel_subscription": "Да, отказвам абонамента",
"cancel_phone_number_subscription_confirmation": "Сигурни ли сте, че искате да прекратите абонамента за този телефонен номер? Това действие не може да бъде отменено и ще загубите достъп до този телефонен номер.",
"add_members": "Добави членове...",
"add_members_no_ellipsis": "Добави членове",
"no_assigned_members": "Няма назначени членове",
"assigned_to": "Назначено на",
"you_must_be_logged_in_to": "Трябва да сте влезли в профила си, за да {{url}}",
@@ -1207,6 +1211,7 @@
"categories": "Категории",
"pricing": "Цени",
"learn_more": "Научи повече",
"try_now": "Пробвайте сега",
"privacy_policy": "Политика за поверителност",
"terms_of_service": "Общи условия",
"remove": "Премахни",
@@ -1440,6 +1445,7 @@
"whatsapp_attendee_action": "изпращане на WhatsApp съобщение до участника",
"workflows": "Работни процеси",
"new_workflow_btn": "Нов работен процес",
"how_would_you_like_to_start": "Как бихте искали да започнете?",
"add_new_workflow": "Добавяне на нов работен процес",
"reschedule_event_trigger": "когато събитието е пренасрочено",
"trigger": "Тригер",
@@ -1716,6 +1722,8 @@
"event_duration_info": "Продължителността на събитието",
"event_time_info": "Началният час на събитието",
"event_type_not_found": "Типът събитие не е намерен",
"number_to_call_variable": "Номер за обаждане",
"number_to_call_info": "Телефонният номер на потребителя, на когото се обаждате",
"location_variable": "Локация",
"location_info": "Локацията на събитието",
"additional_notes_variable": "Допълнителни бележки",
@@ -1753,6 +1761,7 @@
"team_url": "URL на екипа",
"team_members": "Членове на екипа",
"more": "Още",
"cal_ai_workflows": "Cal.ai работни процеси",
"and_count_more": "и още {{count}}",
"more_page_footer": "Разглеждаме мобилното приложение като разширение на уеб приложението. Ако извършвате сложни действия, моля, върнете се към уеб приложението.",
"workflow_example_1": "Изпрати SMS напомняне 24 часа преди началото на събитието до участника",
@@ -1761,6 +1770,18 @@
"workflow_example_4": "Изпрати имейл напомняне 1 час преди началото на събитията до участника",
"workflow_example_5": "Изпрати персонализиран имейл до домакина, когато събитието е пренасрочено",
"workflow_example_6": "Изпрати персонализиран SMS до домакина, когато е резервирано ново събитие",
"send_sms_reminder": "Изпращане на SMS напомняне",
"send_sms_reminder_description": "24 часа преди началото на събитието",
"follow_up_with_no_shows": "Проследяване на неявилите се",
"follow_up_with_no_shows_description": "30 минути след края на събитието",
"remind_attendees_to_bring_id": "Напомняне на участниците да носят лична карта",
"remind_attendees_to_bring_id_description": "1 ден преди началото на събитието",
"email_to_remind_booking": "Имейл напомняне",
"email_to_remind_booking_description": "1 час преди началото на събитието",
"custom_sms_reminder": "Персонализирано SMS напомняне",
"custom_sms_reminder_description": "Когато събитието е планирано",
"custom_email_reminder": "Персонализирано имейл напомняне",
"custom_email_reminder_description": "Събитието е пренасрочено за домакина",
"count_managed_to_limit": "Включване на броя резервации от управляваните типове събития",
"welcome_to_cal_header": "Добре дошли в {{appName}}!",
"edit_form_later_subtitle": "Ще можете да редактирате това по-късно.",
@@ -2184,6 +2205,8 @@
"show_on_booking_page": "Покажи на страницата за резервации",
"visit_cancelled_booking": "Можете да посетите страницата с отменената резервация",
"get_started_zapier_templates": "Започнете със Zapier шаблони",
"standard_templates": "Стандартни шаблони",
"cal_ai_templates": "Cal.ai шаблони",
"team_is_unpublished": "{{team}} не е публикуван",
"org_is_unpublished_description": "Тази връзка към организацията в момента не е достъпна. Моля, свържете се със собственика на организацията или го помолете да я публикува.",
"team_is_unpublished_description": "Тази връзка към екипа в момента не е достъпна. Моля, свържете се със собственика на екипа или го помолете да я публикува.",
@@ -2341,7 +2364,6 @@
"could_not_charge_card": "Не можа да се извърши плащане с картата.",
"insights": "Анализи",
"routing_forms": "Формуляри за маршрутизиране",
"testing_workflow_info_message": "Когато тествате този работен процес, имайте предвид, че имейлите и SMS съобщенията могат да бъдат планирани само поне 1 час предварително",
"insights_no_data_found_for_filter": "Не са намерени данни за избрания филтър или избраните дати.",
"acknowledge_booking_no_show_fee": "Потвърждавам, че ако не присъствам на това събитие, ще бъде начислена такса за неявяване в размер на {{amount, currency}} на моята карта.",
"days": "дни",
@@ -2464,7 +2486,15 @@
"insights_team_filter": "Екип: {{teamName}}",
"insights_user_filter": "Потребител: {{userName}}",
"insights_subtitle": "Преглед на статистики за резервации за всички ваши събития",
"call_history": "История на обажданията",
"call_history_subtitle": "Преглед на историята на обажданията през вашите Cal.ai разговори",
"location_options": "{{locationCount}} опции за локация",
"channel_type": "Тип канал",
"end_reason": "Причина за край",
"session_status": "Статус на сесията",
"user_sentiment": "Настроение на потребителя",
"time_header": "Време",
"from_header": "От",
"custom_plan": "Персонализиран план",
"email_embed": "Вграждане в имейл",
"add_times_to_your_email": "Изберете няколко свободни часа и ги вградете във вашия имейл",
@@ -2752,6 +2782,8 @@
"account_already_linked": "Профилът вече е свързан",
"send_email": "Изпрати имейл",
"cal_ai_phone_call_action": "Обаждане до участника чрез Cal.ai гласов агент",
"call_to_confirm_booking": "Обаждане за потвърждаване на резервация",
"cal_ai_phone_call_action_description": "2 часа преди началото на събитието",
"cal_ai_agent_configuration": "Конфигурация на Cal.ai агент",
"choose_at_least_one_event_type_test_call": "Моля, изберете поне един тип събитие, за да направите тестово обаждане.",
"mark_as_no_show": "Маркирай като неявил се",
@@ -3203,9 +3235,15 @@
"verify_email_change": "Потвърди промяната на имейл",
"buy_credits": "Купете кредити",
"credits": "Кредити",
"view_and_manage_credits": "Преглед и управление на кредити",
"credits_used": "Използвани кредити",
"total_credits_remaining": "Общо оставащи",
"credits_per_tip_org": "Получавате 1000 кредита на месец за всеки член на екипа",
"credits_per_tip_teams": "Получавате 750 кредита на месец за всеки член на екипа",
"view_and_manage_credits": "Преглед и управление на кредити за изпращане на SMS съобщения",
"view_and_manage_credits_description": "Преглед и управление на кредити за изпращане на SMS съобщения. Един кредит струва 1¢ (USD). <0>Научете повече</0>",
"credit_worth_description": "Един кредит струва 1¢ (USD). <0>Научете повече</0>",
"buy_additional_credits": "Купете допълнителни кредити ($0.01 на кредит)",
"view_additional_credits_expense_tip": "Можете да видите допълнителните разходи за кредити в дневника на разходите си",
"overview": "Общ преглед",
"organization_slug_taken": "Слъгът на организацията вече е зает",
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "Не можете да създадете организация, тъй като вече сте част от организация",
@@ -3386,10 +3424,13 @@
"credit_limit_reached_message": "Вашият Cal.com екип {{teamName}} е изчерпал кредитите си. В резултат на това SMS съобщенията сега се изпращат чрез имейл. За да възобновите изпращането на SMS, моля, закупете допълнителни кредити.",
"credit_limit_reached_message_user": "Кредитите на вашия Cal.com акаунт са изчерпани. В резултат на това SMS съобщенията сега се изпращат чрез имейл. За да възобновите изпращането на SMS, моля, закупете допълнителни кредити.",
"current_credit_balance": "Текущ баланс: {{balance}} кредита",
"current_balance": "Текущ баланс:",
"notification_about_your_booking": "Известие за вашата резервация",
"monthly_credits": "Месечни кредити",
"total_credits": "Общо кредити: {{totalCredits}}",
"remaining_credits": "Оставащи кредити: {{remainingCredits}}",
"remaining": "Оставащи",
"total": "Общо",
"additional_credits": "Допълнителни кредити",
"routing_form_next_in_queue": "{{count}} следващи на опашката",
"routing_form_select_members_to_email": "Изпращане на имейл отговори до",
@@ -3467,6 +3508,11 @@
"pbac_desc_view_workflows": "Преглед на съществуващите работни процеси и техните конфигурации",
"pbac_desc_update_workflows": "Редактиране и модифициране на настройките на работните процеси",
"pbac_desc_delete_workflows": "Премахване на работни процеси от системата",
"pbac_resource_webhook": "Уебхук",
"pbac_desc_create_webhooks": "Създаване на уебхуци",
"pbac_desc_view_webhooks": "Преглед на уебхуци",
"pbac_desc_update_webhooks": "Актуализиране на уебхукове",
"pbac_desc_delete_webhooks": "Изтриване на уебхукове",
"pbac_desc_manage_workflows": "Пълен достъп за управление на всички работни процеси",
"pbac_desc_create_event_types": "Създаване на типове събития",
"pbac_desc_view_event_types": "Преглед на типове събития",
@@ -3594,6 +3640,7 @@
"webhook_trigger_event": "Името на задействащото събитие (напр. BOOKING_CREATED, BOOKING_CANCELLED)",
"webhook_created_at": "Времето на уебхука",
"webhook_type": "Слъгът на типа събитие",
"set_up_agent": "Настройка на агент",
"webhook_title": "Името на типа събитие",
"webhook_start_time": "Началното време на събитието",
"webhook_end_time": "Крайното време на събитието",
@@ -3625,6 +3672,9 @@
"visit": "Посети",
"location_custom_label_input_label": "Персонализиран етикет на страницата за резервация",
"meeting_link": "Линк за среща",
"session_outcome": "Резултат от сесията",
"call_created": "Създадено обаждане",
"voicemail": "Гласова поща",
"my_bookings": "Моите резервации",
"phone": "Телефон",
"free": "Безплатно",
@@ -3632,6 +3682,8 @@
"user_name": "Име на потребителя",
"expand_panel": "Разгъни панела",
"collapse_panel": "Свий панела",
"email_verification_required": "Изисква се имейл верификация за този тип събитие",
"invalid_verification_code": "Предоставен е невалиден код за верификация",
"you_have_one_team": "Имате един екип",
"consider_consolidating_one_team_org": "Помислете за създаване на организация, за да обедините таксуването, административните инструменти и анализите за вашия екип.",
"consider_consolidating_multi_team_org": "Помислете за създаване на организация, за да обедините таксуването, административните инструменти и анализите за вашите екипи.",
@@ -3652,5 +3704,32 @@
"before_scheduled_start_time": "Преди планираното начално време",
"cancel_booking_acknowledge_no_show_fee": "Потвърждавам, че при отмяна на резервацията в рамките на {{timeValue}} {{timeUnit}} преди началния час ще ми бъде начислена такса за неявяване в размер на {{amount, currency}}",
"contact_organizer": "Ако имате въпроси, моля, свържете се с организатора.",
"booking_time_option": "Време на резервация",
"booking_time_option_description": "Когато резервацията е планирана (от начало до край)",
"created_at_option": "Създадено на",
"created_at_option_description": "Когато резервацията е първоначално създадена",
"call_details": "Детайли на обаждането",
"call_id": "ID на обаждането",
"call_information": "Информация за обаждането",
"sentiment": "Настроение",
"disconnect_reason": "Причина за прекъсване",
"call_summary": "Обобщение на обаждането",
"transcription": "Транскрипция",
"event_details": "Детайли на събитието",
"agent": "Агент",
"no_transcript_available": "Няма наличен транскрипт",
"testing_sms_workflow_info_message": "Когато тествате този работен процес, имайте предвид, че SMS трябва да бъдат планирани поне 15 минути предварително",
"start_from_scratch_title": "Започнете от нулата",
"start_from_scratch_description": "Създайте свой собствен работен процес от нулата.",
"cal_ai_template_title": "Cal.ai шаблон",
"cal_ai_template_description": "AI агенти, които резервират срещи, изпращат напомняния и проследяват!",
"voice": "Глас",
"select_voice": "Избери глас",
"select_voice_for_agent": "Избери глас за твоя агент",
"choose_a_voice_for_your_agent": "Избери глас за твоя агент",
"trait": "Характеристика",
"voice_id": "ID на глас",
"use_voice": "Използвай глас",
"current_voice": "Текущ глас",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавете новите си низове над този ред ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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