Compare commits

..
Author SHA1 Message Date
github-actions[bot] a48a1bd5d8 chore: version packages (#24062)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-27 14:12:00 +00:00
Kartik Labhshetwar 1acd0a14ba improve: improve mobile layout for API keys settings and dialog (#24114) 2025-09-27 12:14:52 +05:30
Lauris Skraucis 6c9ac7636b docs: v2 disabled input (#24071)
* docs: v2 disabled input

* Delete apps/api/v2/swagger/documentation.json
2025-09-26 18:23:21 +00:00
Amit Sharma d261a23dbc fix: Bunch of fixes and minor changes throughout the app (#23356)
* Bunch of fixes and minor changes throughout the app

* remove logs

* fix: e2e

* fix: e2e

* fix: more e2e and design

* revert insights upgrade change
2025-09-26 12:25:49 +00:00
sean-brydonandcoderabbitai[bot] 8b7947a172 fix: remove legacy logic into legacy service (#24095)
* fix: remove legacy logic into legacy service

* Update packages/features/pbac/services/role-management.factory.ts

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-26 11:56:44 +00:00
Keith Williams 89789b1e3b chore: Update CODEOWNERS to use @calcom/API team for API changes (#24100) 2025-09-26 11:46:03 +00:00
Eunjae LeeandDevin AI 43dd24c480 refactor: migrate TeamEventTypeForm to use PBAC instead of isTeamAdminOrOwner (#24034)
* refactor: migrate TeamEventTypeForm to use PBAC instead of isTeamAdminOrOwner

- Replace isTeamAdminOrOwner prop with permissions.canCreateEventType
- Move permission checks to server-side using PermissionCheckService
- Use eventType.create permission string as specified in PBAC guide
- Update all parent components: CreateEventTypeDialog, event-types-view, CreateEventTypePlatformWrapper
- Follow existing PBAC patterns from event-types-listing-view.tsx
- Maintain backward compatibility with role-based fallback

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

* fix: implement proper server-side PBAC permission checks

- Add eventType.create permission checks to TRPC teams.get handler
- Add PBAC permission checks to platform /organizations/{orgId}/teams/me endpoint
- Update all three components to use server-side permission data instead of client-side async calls
- Add canCreateEventTypes property to platform team types
- Maintain backward compatibility with role-based fallbacks
- Remove unused imports and variables

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

* fix: update client components to use server-side PBAC permission data

- Update event-types-view.tsx to use team.canCreateEventTypes from server
- Update CreateEventTypeDialog.tsx to use team.canCreateEventTypes with fallback
- Update CreateEventTypePlatformWrapper.tsx to use team.canCreateEventTypes with fallback
- Remove hardcoded permission values and role-based checks
- Maintain backward compatibility with existing role-based logic as fallback

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

* apply correct PBAC for event type creation

* revert unexpected changes

* clean up

* address feedback

* fix type error

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-26 13:40:27 +02:00
Udit Takkar ae6b1d402a feat: add create invite link endpoint (#24073)
* feat: add create invite link endpoint

* tests: add e2e test

* chore: feeback

* chore: feeback

* chore; udate summary

* chore; udate summary

* chore: deelte swagger
2025-09-26 10:24:44 +00:00
Rajiv SahalandVolnei Munhoz 3054ad33b2 feat: add docs for calendar view atom (#24049)
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-09-26 09:49:28 +00:00
a4dbb1a90b refactor: replace isTeamAdminOrOwner with PBAC team.listMembers permission (#24006)
* refactor: replace isTeamAdminOrOwner with PBAC team.listMembers permission

- Add canListMembers prop to BookingsProps interface
- Implement server-side permission check using PermissionCheckService
- Handle organization vs team context as specified
- Use ADMIN/OWNER fallback roles for backward compatibility
- Replace user?.isTeamAdminOrOwner check in bookings column filter
- Fix React Hook dependency arrays for ESLint compliance

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

* refactor: wrap canListMembers in permissions object and simplify server logic

- Wrap canListMembers in permissions object for future extensibility
- Simplify server-side logic to only use getTeamIdsWithPermission
- Remove unused imports (prisma, MembershipRole)
- Address user feedback on PR #24006

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

* docs: add comment explaining canListMembers UI logic

- Clarify that teamIdsWithPermission.length > 0 check is for UI purposes
- Actual accurate filtering happens server-side for filter values

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

* feat: add e2e test for member filter visibility

- Verify that users with the MEMBER role cannot see the member filter
- Test creates team with ADMIN and MEMBER users
- Confirms UI correctly reflects PBAC permissions
- Address user feedback on PR #24006

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

* fix: correct e2e test team member creation pattern

- Replace invalid teamId property with hasTeam and teammates pattern
- Fix TypeScript error in booking-filters.e2e.ts
- Resolve CI type check failure

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

* fix e2e test

* remove booking.read permission from member

* add guide

* update

* resource scope

* fix markdown

* update usage

* update guide

---------

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-26 06:26:20 -03:00
689150d78a fix(ui/auth): align email focus ring with password/username (#23885)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-09-26 07:39:01 +00:00
Benny JooandMorgan fba096f23d refactor: use permission service for team delete trpc endpoint (#24077)
* refactor: use permission service for team delete trpc endpoint

* add safety check

* throw forbidden

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-09-26 08:09:23 +01:00
AnshumancanrockandAnik Dhabal Babu 7d5a3391f1 fix: hydration issue ( Step 3 is not visible for all analytics apps ) (#24089)
* booking cancellation reason overflow fix

* apps-prod-issue

* resolve Step 3 blank screen in app installation flow in production

* resolve resolve Step 3 blank screen in app installation flow in production

* Update step-view.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-26 07:07:24 +00:00
Rajiv Sahal dcef99bf49 fix: update org billing portal service to handle platform orgs (#24088) 2025-09-26 05:56:25 +00:00
Anshumancanrock 805fcc22a5 booking cancellation reason overflow fix (#24072) 2025-09-26 03:34:03 +00:00
Volnei Munhoz c5b7da8ffd disable tui (#24076) 2025-09-25 20:16:20 +00:00
Anik Dhabal Babu 97f1f7b130 chore: add content type to ics file (#24069)
* chore: add content type into ics file

* Update generateIcsFile.ts
2025-09-25 18:44:21 +00:00
Alex van Andel 0ac460fce3 chore: when performing read operations we should use Promise.all, not $transaction (#24084) 2025-09-25 17:56:10 +00:00
Alex van Andel 82dfca8272 chore: Add createdAt default to eventtype table and remove extension (#24075) 2025-09-25 17:32:11 +00:00
Eunjae LeeandDevin AI d1c5576d4f feat: add availability and ooo permissions to PBAC registry (#24081)
* feat: add availability and ooo permissions to PBAC registry

- Add Availability and OutOfOffice resources to Resource enum
- Add CRUD permissions for both resources with empty scope arrays
- Create migration to seed admin_role with all CRUD permissions
- Create migration to seed member_role with read-only permissions

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

* feat: add i18n entries for availability and ooo permissions

- Add pbac_resource_availability and pbac_resource_out_of_office resource names
- Add description entries for all CRUD operations on both resources
- Follow existing PBAC i18n pattern for consistency

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-25 16:55:35 +00:00
Morgan 7090e3ed60 chore: remove atoms e2e for now (#24085)
* chore: remove atoms e2e for now

* fixup! chore: remove atoms e2e for now

* fixup! fixup! chore: remove atoms e2e for now
2025-09-25 17:12:10 +01:00
Volnei Munhoz 9926fd2e79 restore npm run all (#24080) 2025-09-25 14:48:02 +00:00
Benny Joo 7793cb20d1 Revert "perf: Replace isTeamMember util with an index DB call (#24066)" (#24078)
This reverts commit 8ed0fb6a07.
2025-09-25 14:35:12 +00:00
Benny Joo 8ed0fb6a07 perf: Replace isTeamMember util with an index DB call (#24066)
* Remove isTeamMember

* updates

* revert

* better
2025-09-25 12:55:14 +00:00
emrysal f5a1222fc5 chore: release v5.7.5
Post release / release (push) Canceled after 0s
2025-09-25 11:59:47 +00:00
Lauris Skraucisandcal.com 2b892296b0 feat: CI job to detect breaking api v2 changes (#24028)
* refactor: move swagger to src/swagger

* refactor: standalone swagger generation script

* refactor: generate only 1 swagger file

* feat: github action checking breaking changes

* chore: ensure openapi file is formatted

* chore: run breaking change check on label

* chore: have only 1 swagger file

* fix: run breaking changes check on workflow call

* refactor: pr breaking jobs dependency

* fix: copy swagger module

* refactor: add check-label as dep

* refactor: breaking changes check part of v2 e2e workflow

* refactor: run breaking changes before e2e

* chore: add vapid env keys to workflow

* chore: add CI_JWT_SECRET to e2e api v2

* chore: add NODE_ENV

---------

Co-authored-by: cal.com <morgan@cal.com>
2025-09-25 14:49:07 +03:00
Eunjae LeeandDevin AI f796802ed3 feat: add fallbackRoles parameter to getTeamIdsWithPermissions method (#24042)
* feat: add fallbackRoles parameter to getTeamIdsWithPermissions method

- Add fallbackRoles parameter to method signature in interface and implementation
- Implement second query for teams without PBAC where user has fallback roles
- Combine and deduplicate results from both PBAC-enabled and fallback role teams
- Supports fallback to role-based permissions when PBAC is disabled
- Fix linting issue in getUserMemberships method by replacing include with select

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

* fix usages

* revert some change

* fix unit tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-25 11:10:41 +01:00
Lauris Skraucis b2ac227877 fix: add missing api v2 swagger DTOs (#24065) 2025-09-25 09:10:27 +00:00
sean-brydon f0aec4efdc fix border appearing in shell (#24003) 2025-09-25 09:07:01 +00:00
Benny Joo 8d7cdf9d3b refactor: use permission check service for isTeamAdmin (#24026)
* saml

* addGuests.handler.ts

* rename canAccess to canAccessOrganization and use organization.read

* update usages

* team update handler

* team member invite

* event type update

* remove isTeamAdmin util

* updateInternalNotesPresets

* setInviteExpiration

* event type update

* deleteInvite

* createInvite

* team publish

* fixes

* final

* address feedback

* update test
2025-09-25 08:54:16 +01:00
2ed0380e4f feat: adds user plan info in useHasPaidPlan for intercom (#23790)
* feat: adds user plan info in `useHasPaidPlan` for intercom

* add to support api route

* Update constants.ts

* sql migration to backfill plans and create/change plans on upgrade/downgrade/create of teams and orgs

* fix: breaking unit tests

* test: add comprehensive tests for billing plan service and team/org flows

- Add unit tests for BillingPlanService.getUserPlanByMemberships() covering all plan determination scenarios
- Add tests for team creation handler verifying TEAMS vs ORGANIZATIONS plan assignment
- Add tests for hasTeamPlan handler integration with BillingPlanService
- Add tests for MembershipRepository.findAllMembershipsByUserIdForBilling() data fetching
- Add tests for InternalTeamBilling upgrade/downgrade flows with proper mocking
- All tests follow existing vitest patterns with proper Prisma and service mocking
- Covers both self-serve and platform billing scenarios with comprehensive edge cases

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* Revert "test: add comprehensive tests for billing plan service and team/org flows"

This reverts commit 58e511f15caf8757c3ec45f6d026caf96ee1a75e.

* fix: make `BillingPlanService` instantiable and use `TeamRepository`

* Revert "fix: make `BillingPlanService` instantiable and use `TeamRepository`"

This reverts commit ae1ff8f15b725566b828864a217d8d0e308b520f.

* revert to runtime calculations. review fixes

* remove uneccessary changes and logs

* review fixes

* review fixes

* fix: type check

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-25 13:00:19 +05:30
Morgan 752cfa6bf9 chore: reduce atoms bundle size prisma types (#24001)
* chore: use kysely types instead of prisma types in atoms

* fixup! chore: use kysely types instead of prisma types in atoms

* fixup! Merge branch 'main' into reduce-atoms-bundle-size-prisma-types
2025-09-25 08:06:16 +05:30
Rajiv Sahal f11c4dd7ec fix: platform billing portal (#23975)
* fix: check if team is platform or not when sending billingPortalUrl

* update team repository

* fix: pass teamId explicitely for platform team

* fix: coderabbit feedback

* fix: merge conflicts

* fix: merge conflicts

* fix: make sure we pass in the correct subsciption id

* fix: implement PR feedback
2025-09-24 20:07:46 +00:00
Keith WilliamsandDevin AI 386f60ca6d refactor: replace prismaMock with BookingRepository mock in checkDurationLimits tests (#24050)
- Replace prismaMock. mocks with BookingRepository.getTotalBookingDuration mocks
- Update return values from [{ totalMinutes: X }] to X to match repository method signature
- Follow existing repository mocking pattern used in other test files
- All tests pass with the new mocking approach

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-24 19:41:54 +00:00
Volnei Munhoz e98f279f64 add react checks for features (#24054) 2025-09-24 18:45:45 +00:00
Eunjae LeeandDevin AI fe190f20f3 refactor: replace isTeamAdminOrOwner with PBAC permissions (#24037)
* refactor: replace isTeamAdminOrOwner with PBAC permissions

- Remove isTeamAdminOrOwner from team-members-view.tsx, rely on server-side permissions
- Replace role checks in addMembersToEventTypes.handler.ts with eventType.update permission
- Follow PBAC refactoring guide patterns for consistent permission checking
- Fix TypeScript any type usage and unused variable warnings

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

* use enum

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-24 13:46:24 +00:00
Volnei MunhozandAlex van Andel 96855ea391 chore: upgrade eslint 9 (#24002)
* add eslint package

* upgrade lint

* remove linting for generated files from trpc

* ts imports

* add missing features lint script

* enable turbo ui

* reference workspace deps correctly

* fixes

* Fix eslint test

* Fix eslint test

* npm run all back

* fix e2e

* fix e2e

* fix e2e

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-24 22:20:49 +09:00
Eunjae Lee 57150d992e fix: make createdById at Watchlist nullable (#24033) 2025-09-24 07:55:27 -03:00
8b75bf347f feat: add BillingCacheService with 1-hour TTL for team subscription data (#23934)
* feat: add BillingCacheService with 1-hour TTL for team subscription data

- Create BillingCacheService following CalendarsCacheService pattern
- Use teamId-based cache keys with 1-hour TTL (3,600,000 ms)
- Integrate caching into getBillingData method in BillingService
- Add cache invalidation to all webhook handlers:
  - handleStripeSubscriptionDeleted
  - handleStripePaymentSuccess
  - handleStripePaymentFailed
  - handleStripePaymentPastDue
  - handleStripeCheckoutEvents
- Add cache invalidation to cancelTeamSubscription method
- Add RedisModule import to billing module
- Add BillingCacheService to billing module providers
- Add findTeamByPlatformBillingId method to OrganizationsRepository for cache invalidation

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

* refactor: implement BillingServiceCachingProxy pattern

- Extract IBillingService interface with all public methods
- Create BillingServiceCachingProxy that implements caching logic
- Remove all caching logic from original BillingService
- Simplify cache invalidation using billing.id = team.id
- Update module to use proxy with proper dependency injection
- Update controller to inject proxy interface
- Remove unused BillingService import from controller

This follows the proxy pattern requested in PR feedback, separating
caching concerns from core billing logic for better maintainability.

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

* chore: add e2e for billing check

* chore: eslint rule for blocking importing features from appstore, lib, prisma (#23832)

* eslint rule

* improve

* fix

* improve msg

* chore: fix any types set by devin

* fix: add mising expect in test

* refactor: move cache methods into BillingServiceCachingProxy

- Remove BillingCacheService abstraction as suggested by @keithwillcode
- Move cache methods directly into proxy as private methods
- Update proxy to inject RedisService directly
- Move BillingData type to interface for better type safety
- Remove BillingCacheService from module providers
- Delete unused billing-cache.service.ts file

This simplifies the architecture by removing unnecessary abstraction
and follows standard caching proxy patterns.

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

* fix: test and legacy starter

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-09-24 07:36:02 -03:00
Eunjae LeeandDevin AI 788fbdbe5e refactor: implement PBAC for team member listing in listSimpleMembers handler (#24005)
* refactor: implement PBAC for team member listing

- Replace membership-based team filtering with getTeamIdsWithPermission
- Use team.listMembers permission for access control
- Maintain fallback to original logic when PBAC fails
- Add comprehensive PBAC refactoring guide for future use

Fixes team fetching logic to use Permission-Based Access Control
while preserving existing functionality and privacy checks.

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

* docs: move PBAC refactoring guide to packages/features/pbac/

Move the PBAC refactoring guide to the appropriate location within
the PBAC feature package for better organization and discoverability.

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

* refactor: remove unnecessary try-catch wrapper

The getTeamIdsWithPermission method already handles all errors internally
and returns an empty array instead of throwing exceptions, making the
try-catch wrapper redundant. Simplified to use direct fallback logic
based on empty array return.

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

* refactor: simplify PBAC implementation

- Remove flawed fallback logic that assumed empty array meant PBAC failure
- Use direct string 'team.listMembers' instead of PermissionMapper
- Remove unused imports (PermissionMapper, Resource, CustomAction)
- Empty array from getTeamIdsWithPermission is legitimate (no permissions)

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

* docs: simplify PBAC refactoring guide

- Reduce from 260 to 87 lines by removing bloated content
- Focus on core pattern: direct permission strings, no fallback logic
- Align with actual PR implementation
- Remove verbose theoretical sections and complex patterns

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-24 08:56:03 +00:00
b2239374bc fix: Remove team members as org admin (#24020)
* Fallback to org admin

* Prevent accidental privilege escalation as code changes in the future

* When org admin, we don't actually need to do the db query

* Use findMany and Map to drill down permission adjustments

* Exclude .MEMBER from overriding role, we likely don't want to demote

* refactor logic

* Add tests for services/factories + removeHandler

* fix type check

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Sean Brydon <sean@cal.com>
2025-09-24 09:33:31 +01:00
Alex van Andel 77c61abef8 remove @id from BookingTimeStatusDenormalized for Prisma .16 support (#24024) 2025-09-24 06:45:33 +00:00
c2472cc05a feat: add createdAt and updatedAt fields to EventType model (#23949)
* feat: add createdAt and updatedAt fields to EventType model

- Add timestamp fields to EventType schema with backward compatibility
- Update all EventType select statements across API v1, v2, and tRPC
- Update platform API output types to include timestamp fields
- Generate migration for existing database records
- Fix test files to handle new timestamp fields properly

The timestamp fields are added as optional (DateTime?) to ensure backward
compatibility - existing EventType records will have null values for these
fields, while new records will automatically get timestamps.

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

* fix: remove @default(now()) from EventType createdAt to prevent existing records from getting current timestamp

- Remove @default(now()) from createdAt field in schema to ensure backward compatibility
- Create migration that only drops DEFAULT constraint without updating existing records
- Existing EventType records will keep null timestamps
- New records will get timestamps via application logic

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

* feat: add createdAt and updatedAt timestamps to all EventType creation locations

- Update all seed scripts to set timestamps for new EventType records
- Ensure consistency across all EventType creation patterns
- Maintain backward compatibility with nullable timestamp fields

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

* feat: add timestamps to remaining EventType creation locations in test files

- Update integration test files to include createdAt and updatedAt
- Ensure consistency across all EventType creation patterns
- Complete comprehensive update of all EventType creation locations

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

* add migration

* few left

* feat: implement Prisma extension for automatic EventType timestamps

- Create eventTypeTimestampsExtension to automatically set createdAt and updatedAt
- Handle both eventType.create and eventType.createMany operations
- Revert all manual timestamp setting from application code
- Extension only sets timestamps if not already provided
- Maintains backward compatibility - existing records keep null timestamps
- New records get automatic timestamps via Prisma extension
- Follows existing Cal.com extension patterns and architecture

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

* feat: remove hardcoded timestamp settings from test files

- Remove manual createdAt/updatedAt settings from routing forms controller test
- Remove manual timestamp settings from event types repository fixture
- Prisma extension now handles timestamps automatically for all EventType operations

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

* update

* Update event-type.output.ts

* fix test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-09-24 06:59:46 +01:00
emrysal 608fe1c586 chore: release v5.7.4
Post release / release (push) Canceled after 0s
2025-09-24 05:21:33 +00:00
Syed Ali Shahbaz 4413295b27 feat: Add spam block schema and migration (#23996)
* schema

* fix migration

* --

* optional org

* --

* remove prisma level relation

* --

* rename

* remove redundant key

* --
2025-09-24 03:50:25 +01:00
Anik Dhabal Babu bcc433ba9a fix: reschedule flow is broken for seated booking (#23987)
* fix: reschedule flow is broken for seated booking

* Update BookingListItem.tsx

* Update

* tweak
2025-09-23 22:05:08 +01:00
sean-brydon 2ba571d230 Attach org to impersonation return (#24012) 2025-09-23 18:34:16 +00:00
Hariom BalharaandAnik Dhabal Babu 4a0a1ef053 fix: Update documentation as per code (#23994)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-23 23:13:00 +05:30
Udit Takkar 7e882eedf8 fix: remove booking question after workflow deletion (#23901)
* fix: remove booking question after workflow deletion

* fix
2025-09-23 15:58:56 +00:00
sean-brydonandcoderabbitai[bot] 224e606439 feat: pbac org billing (#23709)
* refactor layout to not check session

* add actions for orgs + org admins

* update types on actions to be correctly non nullable

* Add tests for utils

* Apply suggestion from @coderabbitai[bot]

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

* restore lock file

* add permission check action for org authentication managment

* WIP BRANCH

* Git merge fix conflicts

* Fix imports

* intro to pbac team billing

* restore log

* refactor to be a billing portal factory service

* fix merge conflict

* Fix merge conflicts

* Passing test with non hardcoded vars

* fix migration

* Wip

* remove layout permision checks

* Fix type check

* remove logs

* improve error handling and logs

* Fix nits

* nits

* Use push instead of slice for billing tab

* Add permissions to memo

* Fix credits handlers to use PBAC also

* fix imports

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-23 14:01:21 +01:00
Morgan 14b3344a41 fix: optional auth guard only secret provided (#23977) 2025-09-23 08:22:45 +00:00
Hariom Balhara a538ba64d2 Add validation tests (#23833) 2025-09-23 04:45:47 +00:00
Adarsh Singh b3bfad7fea fix(ui): Correct phone number dropdown position in RTL layouts (#23964)
* fix(ui): correct phone input dropdown position in RTL

* chore: Remove explanatory comments per review
2025-09-23 01:22:06 +00:00
emrysal f80b45e36a chore: release v5.7.3
Post release / release (push) Canceled after 0s
2025-09-23 00:52:24 +00:00
f3d60b141f fix: resolve 404 error when rescheduling seated event bookings (#23956)
* fix: skip select field validation for existing responses during reschedule

- Modify hasRequiredBookingFieldsResponses to detect reschedule context
- Skip validation for select, multiselect, checkbox, and radio fields during reschedule to preserve existing responses
- Fixes validation error when rescheduling bookings with select field responses that may no longer match current field options

Resolves issue where reschedule operations fail with 'Value Travel, Adventure & Wildlife is not valid for type select for field Niche-project' error

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

* fix: skip frontend validation for existing select field responses during reschedule

- Revert backend changes in bookings.service.ts as the issue is frontend-only
- Add reschedule detection in getBookingResponsesSchema.ts preprocess function
- Skip option validation for select, radio, multiselect, and checkbox fields during reschedule
- Preserves existing booking responses that may no longer match current field options
- Fixes error 'Value Travel, Adventure & Wildlife is not valid for type select for field Niche-project' during slot selection

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

* fix: extend reschedule validation skip to handle multiemail fields like guests

- Add reschedule-aware logic to multiemail validation in getBookingResponsesSchema.ts
- Skip validation for existing multiemail responses during reschedule to avoid type mismatch errors
- Fixes 'Value is not valid for type text for field guests' error during slot selection
- Complements existing fix for select/radio field validation during reschedule

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

* fix: disable guests field inputs for seated events

- Disable type SelectField for guests field in seated events
- Disable identifier InputField for guests field in seated events
- Add validation to prevent saving guests field changes for seated events
- Pass seatsEnabled prop from EventAdvancedTab to FormBuilder
- Use condition: seatsEnabled && formFieldType === 'multiemail' && fieldForm.getValues('name') === 'guests'
- Fix linting warnings: remove unused variable and add missing dependency

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

* Update getBookingResponsesSchema.ts

* Update FormBuilder.tsx

* Update FormBuilder.tsx

* update

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-22 23:44:46 +00:00
5463dc261c fix: prevent external CSS from breaking Cal.com embed (#22778)
* fix: added block display to .cal-embed

* fixed the iframe color-scheme to unset and bg transparent

* removed forced CSS

* changes reverted from iframe

* Add comments and remove background transparent as it is still not needed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-09-22 19:10:06 +00:00
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
500 changed files with 14645 additions and 34320 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
fix: EventTypeSettings Checkbox booking field label
-9
View File
@@ -1,9 +0,0 @@
node_modules
apps/api/v2/dist
packages/platform/**/dist/*
**/**/node_modules
**/**/.next
**/**/public
packages/prisma/zod
apps/web/public/embed
packages/ui/components/icon/dynamicIconImports.tsx
-115
View File
@@ -1,115 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
plugins: ["import"],
rules: {
"import/no-cycle": ["warn", { maxDepth: Infinity }],
},
overrides: [
// WARN: features must not be imported by app-store or lib
{
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
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",
{
patterns: [
{
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.",
},
],
},
],
},
},
],
};
+1 -1
View File
@@ -1,5 +1,5 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/api/**/* @calcom/API
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/csp.ts @calcom/Foundation
+1 -7
View File
@@ -74,14 +74,8 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
e2e-atoms:
name: Tests
needs: [lint, build, build-atoms, build-api-v2]
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
+18 -1
View File
@@ -1,4 +1,4 @@
name: E2E
name: Check breaking changes and run E2E
on:
workflow_call:
env:
@@ -23,6 +23,10 @@ env:
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
NODE_ENV: ${{ vars.CI_NODE_ENV }}
jobs:
e2e:
timeout-minutes: 20
@@ -68,6 +72,18 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- name: Generate Swagger
working-directory: apps/api/v2
run: yarn generate-swagger
- name: Check breaking changes
uses: oasdiff/oasdiff-action/breaking@main
with:
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
revision: docs/api-reference/v2/openapi.json
fail-on: WARN
- name: Run Tests
working-directory: apps/api/v2
run: |
@@ -75,6 +91,7 @@ jobs:
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() }}
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
organization-name: calcom
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier"
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
apply-labels-from-issue:
runs-on: ubuntu-latest
+2 -10
View File
@@ -195,13 +195,6 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
e2e-atoms:
name: Tests
needs: [changes, check-label, build, build-atoms, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [build]
@@ -211,7 +204,7 @@ jobs:
merge-reports:
name: Merge reports
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
uses: ./.github/workflows/merge-reports.yml
secrets: inherit
@@ -255,8 +248,7 @@ jobs:
e2e-api-v2,
e2e-embed,
e2e-embed-react,
e2e-app-store,
e2e-atoms,
e2e-app-store
]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
-1
View File
@@ -1 +0,0 @@
20
-1
View File
@@ -16,5 +16,4 @@ public
packages/prisma/zod
packages/prisma/enums
apps/web/public/embed
apps/api/v2/swagger/documentation.json
packages/ui/components/icon/dynamicIconImports.tsx
View File
View File
View File
+3
View File
@@ -13,5 +13,8 @@
"connect": "^3.7.0",
"http": "^0.0.1-security",
"http-proxy-middleware": "^2.0.6"
},
"devDependencies": {
"@calcom/eslint-config": "workspace:*"
}
}
+3
View File
@@ -0,0 +1,3 @@
import { nextJsConfig } from "@calcom/eslint-config/next-js";
export default nextJsConfig;
+11 -10
View File
@@ -10,7 +10,7 @@
"build": "next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "PORT=3003 next dev",
"lint": "eslint . --ignore-path .gitignore",
"lint": "eslint .",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"start": "PORT=3003 next start",
"docker-start-api": "PORT=80 next start",
@@ -18,18 +18,19 @@
"type-check:ci": "tsc-absolute --pretty --noEmit"
},
"devDependencies": {
"@calcom/tsconfig": "*",
"@calcom/types": "*",
"@calcom/eslint-config": "workspace:*",
"@calcom/tsconfig": "workspace:*",
"@calcom/types": "workspace:*",
"node-mocks-http": "^1.11.0"
},
"dependencies": {
"@calcom/app-store": "*",
"@calcom/dayjs": "*",
"@calcom/emails": "*",
"@calcom/features": "*",
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
"@calcom/app-store": "workspace:*",
"@calcom/dayjs": "workspace:*",
"@calcom/emails": "workspace:*",
"@calcom/features": "workspace:*",
"@calcom/lib": "workspace:*",
"@calcom/prisma": "workspace:*",
"@calcom/trpc": "workspace:*",
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
@@ -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";
@@ -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";
@@ -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";
+1 -1
View File
@@ -59,7 +59,7 @@ export async function getHandler(req: NextApiRequest) {
};
}
const [total, data] = await prisma.$transaction([
const [total, data] = await Promise.all([
prisma.user.count({ where }),
prisma.user.findMany({ where, take, skip }),
]);
@@ -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(),
};
-31
View File
@@ -1,31 +0,0 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint/eslint-plugin"],
extends: ["plugin:@typescript-eslint/recommended"],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
rules: {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
},
overrides: [
{
files: ["./src/**/*.controller.ts"],
excludedFiles: "*.spec.js",
rules: {
"@typescript-eslint/explicit-function-return-type": "error",
},
},
],
};
+10 -8
View File
@@ -16,7 +16,7 @@
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
@@ -30,18 +30,19 @@
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
"prepare": "yarn run snyk-protect",
"snyk-protect": "snyk-protect"
},
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "*",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@calcom/platform-constants": "workspace:*",
"@calcom/platform-enums": "workspace:*",
"@calcom/platform-libraries": "workspace:*",
"@calcom/platform-types": "workspace:*",
"@calcom/platform-utils": "workspace:*",
"@calcom/prisma": "workspace:*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nest-lab/throttler-storage-redis": "1.0.0",
@@ -88,6 +89,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@calcom/eslint-config": "workspace:*",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
@@ -1,10 +1,11 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
@ApiExtraModels(ReassignBookingOutputData_2024_08_13)
export class ReassignBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -7,8 +7,6 @@ import {
BookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
+12 -108
View File
@@ -1,39 +1,29 @@
import type { AppConfig } from "@/config/type";
import { getEnv } from "@/env";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import {
PathItemObject,
PathsObject,
OperationObject,
TagObject,
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
import "dotenv/config";
import * as fs from "fs";
import { Server } from "http";
import { WinstonModule } from "nest-winston";
import { bootstrap } from "./app";
import { AppModule } from "./app.module";
import { loggerConfig } from "./lib/logger";
import { generateSwaggerForApp } from "./swagger/generate-swagger";
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
const run = async () => {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
bodyParser: false,
});
run().catch((error: Error) => {
console.error("Failed to start Cal Platform API", { error: error.stack });
process.exit(1);
});
async function run() {
const app = await createNestApp();
const logger = new Logger("App");
try {
bootstrap(app);
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
void generateSwagger(app);
generateSwaggerForApp(app);
await app.listen(port);
logger.log(`Application started on port: ${port}`);
} catch (error) {
@@ -42,97 +32,11 @@ const run = async () => {
error,
});
}
};
function customTagSort(a: string, b: string): number {
const platformPrefix = "Platform";
const orgsPrefix = "Orgs";
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
return -1;
}
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
return 1;
}
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
return -1;
}
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
return 1;
}
return a.localeCompare(b);
}
function isOperationObject(obj: any): obj is OperationObject {
return obj && typeof obj === "object" && "tags" in obj;
}
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
const groupedPaths: { [key: string]: PathsObject } = {};
Object.keys(paths).forEach((pathKey) => {
const pathItem = paths[pathKey];
HttpMethods.forEach((method) => {
const operation = pathItem[method];
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
const firstTag = operation.tags[0];
if (!groupedPaths[firstTag]) {
groupedPaths[firstTag] = {};
}
groupedPaths[firstTag][pathKey] = pathItem;
}
});
export async function createNestApp() {
return NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
bodyParser: false,
});
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
const sortedPaths: PathsObject = {};
sortedTags.forEach((tag) => {
Object.assign(sortedPaths, groupedPaths[tag]);
});
return sortedPaths;
}
async function generateSwagger(app: NestExpressApplication<Server>) {
const logger = new Logger("App");
logger.log(`Generating Swagger documentation...\n`);
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const swaggerOutputFile = "./swagger/documentation.json";
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
const stringifiedContents = JSON.stringify(document, null, 2);
if (fs.existsSync(swaggerOutputFile)) {
fs.unlinkSync(swaggerOutputFile);
}
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
fs.unlinkSync(docsOutputFile);
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
}
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
customCss: ".swagger-ui .topbar { display: none }",
});
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
}
}
run().catch((error: Error) => {
console.error("Failed to start Cal Platform API", { error: error.stack });
process.exit(1);
});
@@ -2,6 +2,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import {
NO_AUTH_PROVIDED_MESSAGE,
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
export class OptionalApiAuthGuard extends ApiAuthGuard {
@@ -9,11 +10,16 @@ export class OptionalApiAuthGuard extends ApiAuthGuard {
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
const onlyClientSecretProvided = error && error.message.includes(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
if (onlyClientIdProvided) {
return null;
}
if (onlyClientSecretProvided) {
return null;
}
if (user || noAuthProvided || !error) {
return user || null;
} else {
@@ -32,7 +32,11 @@ 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, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
import {
ApiAuthGuardRequest,
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
} from "./api-auth.strategy";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -267,6 +271,29 @@ describe("ApiAuthStrategy", () => {
}
});
it("should throw 401 if only OAuth Client Secret is provided", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_SECRET_KEY]: `${oAuthClient.secret}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_SECRET_PROVIDED_MESSAGE);
}
}
});
it("should throw 401 if OAuth ID is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
@@ -29,11 +29,11 @@ export type ApiAuthGuardRequest = Request & {
user: ApiAuthGuardUser;
allowedAuthMethods?: AllowedAuthMethod[];
};
export const NO_AUTH_PROVIDED_MESSAGE =
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
export const NO_AUTH_PROVIDED_MESSAGE = `No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as '${X_CAL_SECRET_KEY}' and '${X_CAL_CLIENT_ID}' headers`;
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE =
"Only 'x-cal-client-id' header provided. Please also provide 'x-cal-secret-key' header or Auth bearer token as 'Authentication' header";
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE = `Only '${X_CAL_CLIENT_ID}' header provided. Please also provide '${X_CAL_SECRET_KEY}' header or Auth bearer token as 'Authentication' header`;
export const ONLY_CLIENT_SECRET_PROVIDED_MESSAGE = `Only '${X_CAL_SECRET_KEY}' header provided. Please also provide '${X_CAL_CLIENT_ID}' header or Auth bearer token as 'Authentication' header`;
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
@@ -121,6 +121,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
const onlyClientSecretProvided =
!oAuthClientId && !!oAuthClientSecret && !bearerToken && !nextAuthToken;
if (noAuthProvided) {
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
}
@@ -129,6 +132,10 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
}
if (onlyClientSecretProvided) {
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_SECRET_PROVIDED_MESSAGE}`);
}
throw new UnauthorizedException(
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
@@ -9,6 +10,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { BullModule } from "@nestjs/bull";
@@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
imports: [
PrismaModule,
StripeModule,
RedisModule,
MembershipsModule,
OrganizationsModule,
BullModule.registerQueue({
@@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
providers: [
BillingConfigService,
BillingService,
{
provide: "IBillingService",
useClass: BillingServiceCachingProxy,
},
BillingRepository,
BillingProcessor,
ManagedOrganizationsBillingService,
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -16,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { withNextAuth } from "test/utils/withNextAuth";
import type { Team, PlatformBilling } from "@calcom/prisma/client";
@@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;
beforeAll(async () => {
const moduleRef = await withApiAuth(
const moduleRef = await withNextAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({
name: `billing-organization-${randomString()}`,
isPlatform: true,
});
user = await userRepositoryFixture.create({
@@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
await app.close();
});
it("/billing/webhook (GET) should not get billing plan for org since it's not set yet", () => {
return request(app.getHttpServer())
.get(`/v2/billing/${organization.id}/check`)
.expect(200)
.then(async (res) => {
const data = res.body.data as CheckPlatformBillingResponseDto;
expect(data?.plan).toEqual("FREE");
});
});
it("/billing/webhook (POST) should set billing free plan for org", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
@@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
expect(billing?.plan).toEqual("FREE");
});
});
it("/billing/webhook (GET) should get billing plan for org", () => {
return request(app.getHttpServer())
.get(`/v2/billing/${organization.id}/check`)
.expect(200)
.then(async (res) => {
const data = res.body.data as CheckPlatformBillingResponseDto;
expect(data?.plan).toEqual("FREE");
});
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
@@ -6,7 +6,7 @@ import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
import { BillingService } from "@/modules/billing/services/billing.service";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { StripeService } from "@/modules/stripe/stripe.service";
import {
Body,
@@ -19,6 +19,7 @@ import {
Headers,
HttpCode,
HttpStatus,
Inject,
Logger,
Delete,
ParseIntPipe,
@@ -40,7 +41,7 @@ export class BillingController {
private logger = new Logger("Billing Controller");
constructor(
private readonly billingService: BillingService,
@Inject("IBillingService") private readonly billingService: IBillingService,
public readonly stripeService: StripeService,
private readonly configService: ConfigService<AppConfig>
) {
@@ -0,0 +1,27 @@
import { PlatformPlan } from "@/modules/billing/types";
import type { StripeService } from "@/modules/stripe/stripe.service";
import Stripe from "stripe";
import { PlatformBilling, Team } from "@calcom/prisma/client";
export type BillingData = {
team: (Team & { platformBilling: PlatformBilling | null }) | null;
status: "valid" | "no_subscription" | "no_billing";
plan: string;
};
export interface IBillingService {
getBillingData(teamId: number): Promise<BillingData>;
createTeamBilling(teamId: number): Promise<string>;
redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise<string>;
updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string>;
cancelTeamSubscription(teamId: number): Promise<void>;
handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void>;
handleStripePaymentSuccess(event: Stripe.Event): Promise<void>;
handleStripePaymentFailed(event: Stripe.Event): Promise<void>;
handleStripePaymentPastDue(event: Stripe.Event): Promise<void>;
handleStripeCheckoutEvents(event: Stripe.Event): Promise<void>;
handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void>;
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null;
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null;
stripeService: StripeService;
}
@@ -0,0 +1,137 @@
import { IBillingService, BillingData } from "@/modules/billing/interfaces/billing-service.interface";
import { BillingService } from "@/modules/billing/services/billing.service";
import { PlatformPlan } from "@/modules/billing/types";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import Stripe from "stripe";
export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`;
export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour
@Injectable()
export class BillingServiceCachingProxy implements IBillingService {
constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {}
async getBillingData(teamId: number) {
const cachedBillingData = await this.getBillingCache(teamId);
if (cachedBillingData) {
return cachedBillingData;
}
const billingData = await this.billingService.getBillingData(teamId);
await this.setBillingCache(teamId, billingData);
return billingData;
}
private async deleteBillingCache(teamId: number) {
await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId));
}
private async getBillingCache(teamId: number) {
const cachedResult = await this.redisService.get<BillingData>(REDIS_BILLING_CACHE_KEY(teamId));
return cachedResult;
}
private async setBillingCache(teamId: number, billingData: BillingData): Promise<void> {
await this.redisService.set<BillingData>(REDIS_BILLING_CACHE_KEY(teamId), billingData, {
ttl: BILLING_CACHE_TTL_MS,
});
}
async createTeamBilling(teamId: number): Promise<string> {
return this.billingService.createTeamBilling(teamId);
}
async redirectToSubscribeCheckout(
teamId: number,
plan: PlatformPlan,
customerId?: string
): Promise<string> {
return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId);
}
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
return this.billingService.updateSubscriptionForTeam(teamId, plan);
}
async cancelTeamSubscription(teamId: number): Promise<void> {
await this.billingService.cancelTeamSubscription(teamId);
await this.deleteBillingCache(teamId);
}
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripeSubscriptionDeleted(event);
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
if (teamId) {
await this.deleteBillingCache(Number.parseInt(teamId));
}
}
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripePaymentSuccess(event);
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
if (subscriptionId) {
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
subscriptionId
);
if (teamBilling?.id) {
await this.deleteBillingCache(teamBilling.id);
}
}
}
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripePaymentFailed(event);
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
if (subscriptionId) {
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
subscriptionId
);
if (teamBilling?.id) {
await this.deleteBillingCache(teamBilling.id);
}
}
}
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripePaymentPastDue(event);
const subscription = event.data.object as Stripe.Subscription;
const subscriptionId = subscription.id;
if (subscriptionId) {
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
subscriptionId
);
if (teamBilling?.id) {
await this.deleteBillingCache(teamBilling.id);
}
}
}
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripeCheckoutEvents(event);
const checkoutSession = event.data.object as Stripe.Checkout.Session;
const teamId = checkoutSession.metadata?.teamId;
if (teamId) {
await this.deleteBillingCache(Number.parseInt(teamId));
}
}
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
return this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
}
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
return this.billingService.getSubscriptionIdFromInvoice(invoice);
}
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
return this.billingService.getCustomerIdFromInvoice(invoice);
}
get stripeService() {
return this.billingService.stripeService;
}
}
@@ -2,6 +2,7 @@ import { AppConfig } from "@/config/type";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
@@ -23,14 +24,14 @@ import { DateTime } from "luxon";
import Stripe from "stripe";
@Injectable()
export class BillingService implements OnModuleDestroy {
export class BillingService implements IBillingService, OnModuleDestroy {
private logger = new Logger("BillingService");
private readonly webAppUrl: string;
constructor(
private readonly teamsRepository: OrganizationsRepository,
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
public readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService,
private readonly usersRepository: UsersRepository,
@@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {
async getBillingData(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (teamWithBilling?.platformBilling) {
if (!teamWithBilling?.platformBilling.subscriptionId) {
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
return { team: teamWithBilling, status: "no_subscription" as const, plan: "none" };
} else {
return {
team: teamWithBilling,
status: "valid" as const,
plan: teamWithBilling.platformBilling.plan,
};
}
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
} else {
return { team: teamWithBilling, status: "no_billing", plan: "none" };
return { team: teamWithBilling, status: "no_billing" as const, plan: "none" };
}
}
async createTeamBilling(teamId: number) {
async createTeamBilling(teamId: number): Promise<string> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let customerId = teamWithBilling?.platformBilling?.customerId;
@@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy {
});
}
return customerId;
return customerId!;
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
@@ -297,6 +297,12 @@ export class DateTimeWithZone {
timeZone!: string;
}
@ApiExtraModels(
CalendarEventVideoLocation,
CalendarEventPhoneLocation,
CalendarEventSipLocation,
CalendarEventMoreLocation
)
export class UnifiedCalendarEventOutput {
@ValidateNested()
@Type(() => DateTimeWithZone)
@@ -4,9 +4,11 @@ import {
MicrosoftServiceAccountKeyInput,
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, IsNotEmpty, ValidateNested, Validate } from "class-validator";
import { IsString, IsNotEmpty, Validate } from "class-validator";
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
export class CreateDelegationCredentialInput {
@IsString()
@IsNotEmpty()
@@ -3,10 +3,11 @@ import {
MicrosoftServiceAccountKeyInput,
ServiceAccountKeyValidator,
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
import { ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Expose, plainToClass, Transform, Type } from "class-transformer";
import { IsBoolean, IsOptional, Validate, ValidateNested } from "class-validator";
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsOptional, Validate } from "class-validator";
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
export class UpdateDelegationCredentialInput {
@IsBoolean()
@IsOptional()
@@ -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 member and team admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
@@ -172,7 +172,7 @@ describe("Organizations Event Types Endpoints", () => {
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
role: "MEMBER",
user: { connect: { id: userAdmin.id } },
team: { connect: { id: org.id } },
accepted: true,
@@ -247,7 +247,7 @@ describe("Organizations Event Types Endpoints", () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
.send(body)
.expect(404);
.expect(403);
});
it("should not be able to create event-type for user outside org", async () => {
@@ -511,7 +511,7 @@ describe("Organizations Event Types Endpoints", () => {
it("should not get an event-type of team outside org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
.expect(404);
.expect(403);
});
it("should not get a non existing event-type", async () => {
@@ -576,7 +576,7 @@ describe("Organizations Event Types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(404);
.expect(403);
});
it("should not be able to update non existing event-type", async () => {
@@ -950,7 +950,7 @@ describe("Organizations Event Types Endpoints", () => {
it("should not delete event-type of team outside org", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
.expect(404);
.expect(403);
});
it("should delete event-type not part of the team", async () => {
@@ -174,4 +174,14 @@ export class OrganizationsRepository {
},
});
}
async findTeamByPlatformBillingId(billingId: number) {
return this.dbRead.prisma.team.findFirst({
where: {
platformBilling: {
id: billingId,
},
},
});
}
}
@@ -47,6 +47,7 @@ import { OrganizationsTeamsController } from "@/modules/organizations/teams/inde
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipsController,
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
OrganizationsTeamsInviteController,
OrganizationsAttributesController,
OrganizationsAttributesOptionsController,
OrganizationsWebhooksController,
@@ -68,7 +68,7 @@ export class ManagedOrganizationsRepository {
managedOrganization: managedOrganizationFilter,
};
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
const [totalItems, linkRows] = await Promise.all([
this.dbRead.prisma.managedOrganization.count({ where }),
this.dbRead.prisma.managedOrganization.findMany({
where,
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
@@ -75,6 +75,7 @@ class Routing {
crmOwnerRecordType?: string;
}
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
export class CreateRoutingFormResponseOutputData {
@ApiPropertyOptional({
type: Number,
@@ -0,0 +1,135 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { Team, User } from "@calcom/prisma/client";
describe("Organizations Teams Invite Endpoints", () => {
describe("User Authentication - User is Org Team Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
const userEmail = `organizations-teams-invite-admin-${randomString()}@api.com`;
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: `organizations-teams-invite-organization-${randomString()}`,
isOrganization: true,
});
orgTeam = await teamsRepositoryFixture.create({
name: `organizations-teams-invite-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: `organizations-teams-invite-non-org-team-${randomString()}`,
isOrganization: false,
});
// Admin of the org team
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
// Also a member of the organization
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create a team invite", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data.token.length).toBeGreaterThan(0);
expect(response.body.data.inviteLink).toEqual(expect.any(String));
expect(response.body.data.inviteLink).toContain(response.body.data.token);
});
});
it("should create a new invite on each request", async () => {
const first = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200);
const firstToken = first.body.data.token as string;
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data.token).not.toEqual(firstToken);
expect(response.body.data.inviteLink).toEqual(expect.any(String));
expect(response.body.data.inviteLink).toContain(response.body.data.token);
});
});
it("should fail for team not in organization", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/invite`)
.expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,43 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { Controller, UseGuards, Post, Param, ParseIntPipe, HttpCode, HttpStatus } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamService } from "@calcom/platform-libraries";
import { CreateInviteOutputDto } from "./outputs/invite.output";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Invite")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsInviteController {
@Post("/invite")
@Roles("TEAM_ADMIN")
@ApiOperation({ summary: "Create team invite link" })
@HttpCode(HttpStatus.OK)
async createInvite(
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<CreateInviteOutputDto> {
const result = await TeamService.createInvite(teamId);
return { status: SUCCESS_STATUS, data: result };
}
}
@@ -0,0 +1,27 @@
import { ApiProperty } from "@nestjs/swagger";
export class InviteDataDto {
@ApiProperty({
description:
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
})
token!: string;
@ApiProperty({
description:
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
example: "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
})
inviteLink!: string;
}
export class CreateInviteOutputDto {
@ApiProperty({ example: "success" })
status!: string;
@ApiProperty({ type: InviteDataDto })
data!: InviteDataDto;
}
@@ -1,9 +1,10 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmptyObject, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
@IsNotEmptyObject()
@ValidateNested()
@@ -1,9 +1,10 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmptyObject, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
@IsNotEmptyObject()
@ValidateNested()
@@ -1,11 +1,11 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
class Output {
class CreateTeamOutputData {
@Expose()
@IsString()
message!: string;
@@ -19,16 +19,17 @@ class Output {
pendingTeam!: TeamOutputDto;
}
@ApiExtraModels(TeamOutputDto, CreateTeamOutputData)
export class CreateTeamOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }],
oneOf: [{ $ref: getSchemaPath(CreateTeamOutputData) }, { $ref: getSchemaPath(TeamOutputDto) }],
description: "Either an Output object or a TeamOutputDto.",
})
@Expose()
@ValidateNested()
data!: Output | TeamOutputDto;
data!: CreateTeamOutputData | TeamOutputDto;
}
@@ -8,8 +8,8 @@ import * as path from "path";
// "nest-cli" with the "nest-cli.json" file, and for nest cli to be loaded with plugins correctly the "@nestjs/swagger"
// should reside in the project's node_modules already before the "nest start" command is executed.
async function copyNestSwagger() {
const monorepoRoot = path.resolve(__dirname, "../../../../");
const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs");
const monorepoRoot = path.resolve(__dirname, "../../../../../");
const nodeModulesNestjs = path.resolve(__dirname, "../../node_modules/@nestjs");
const swaggerModulePath = "@nestjs/swagger";
const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath);
@@ -0,0 +1,29 @@
import "dotenv/config";
import { bootstrap } from "../app";
import { createNestApp } from "../main";
import { generateSwaggerForApp } from "../swagger/generate-swagger";
generateSwagger()
.then(() => {
console.log("✅ Swagger generation completed successfully");
process.exit(0);
})
.catch((error: Error) => {
console.error("❌ Failed to generate swagger", { error: error.stack });
process.exit(1);
});
async function generateSwagger() {
const app = await createNestApp();
try {
bootstrap(app);
await generateSwaggerForApp(app);
} catch (error) {
console.error(error);
throw error;
} finally {
await app.close();
}
}
@@ -0,0 +1,97 @@
import { getEnv } from "@/env";
import { Logger } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import {
PathItemObject,
PathsObject,
OperationObject,
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
import "dotenv/config";
import * as fs from "fs";
import { Server } from "http";
import { spawnSync } from "node:child_process";
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
export async function generateSwaggerForApp(app: NestExpressApplication<Server>) {
const logger = new Logger("App");
logger.log(`Generating Swagger documentation...\n`);
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
const stringifiedContents = JSON.stringify(document, null, 2);
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
fs.unlinkSync(docsOutputFile);
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
spawnSync("npx", ["prettier", docsOutputFile, "--write"], { stdio: "inherit" });
}
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
customCss: ".swagger-ui .topbar { display: none }",
});
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
}
}
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
const groupedPaths: { [key: string]: PathsObject } = {};
Object.keys(paths).forEach((pathKey) => {
const pathItem = paths[pathKey];
HttpMethods.forEach((method) => {
const operation = pathItem[method];
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
const firstTag = operation.tags[0];
if (!groupedPaths[firstTag]) {
groupedPaths[firstTag] = {};
}
groupedPaths[firstTag][pathKey] = pathItem;
}
});
});
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
const sortedPaths: PathsObject = {};
sortedTags.forEach((tag) => {
Object.assign(sortedPaths, groupedPaths[tag]);
});
return sortedPaths;
}
function customTagSort(a: string, b: string): number {
const platformPrefix = "Platform";
const orgsPrefix = "Orgs";
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
return -1;
}
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
return 1;
}
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
return -1;
}
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
return 1;
}
return a.localeCompare(b);
}
function isOperationObject(obj: any): obj is OperationObject {
return obj && typeof obj === "object" && "tags" in obj;
}
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,7 @@ export class PlatformBillingRepositoryFixture {
id: orgId,
customerId: `cus_123_${randomString}`,
subscriptionId: `sub_123_${randomString}`,
plan: plan || "STARTER",
plan: plan || "FREE",
},
});
}
+3
View File
@@ -0,0 +1,3 @@
import { nextJsConfig } from "@calcom/eslint-config/next-js";
export default nextJsConfig;
+6 -8
View File
@@ -6,12 +6,12 @@
"dev": "next dev --turbopack --port=1337",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"dependencies": {
"@calcom/config": "*",
"@calcom/lib": "*",
"@calcom/ui": "*",
"@calcom/config": "workspace:*",
"@calcom/lib": "workspace:*",
"@calcom/ui": "workspace:*",
"@radix-ui/react-tooltip": "^1.1.8",
"airtable": "^0.12.2",
"fumadocs-core": "^14.5.4",
@@ -24,14 +24,12 @@
"react-hot-toast": "^2.5.2"
},
"devDependencies": {
"@calcom/tsconfig": "*",
"@eslint/eslintrc": "^3",
"@calcom/eslint-config": "workspace:*",
"@calcom/tsconfig": "workspace:*",
"@types/mdx": "^2.0.13",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8.34.0",
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5.9.0-beta"
-2
View File
@@ -1,2 +0,0 @@
node_modules
public/embed
@@ -6,6 +6,8 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -33,9 +35,30 @@ const Page = async ({ params }: PageProps) => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
let canReadOthersBookings = false;
if (session?.user?.id) {
const permissionService = new PermissionCheckService();
const userId = session.user.id;
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId,
permission: "booking.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
// We check if teamIdsWithPermission.length > 0.
// While this may not be entirely accurate, it's acceptable
// because we perform a thorough validation on the server side for the actual filter values.
// This variable is primarily for UI purposes.
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
/>
</ShellMainAppDir>
);
};
@@ -11,6 +11,7 @@ export const TeamsCTA = () => {
data-testid="new-team-btn"
variant="fab"
StartIcon="plus"
size="sm"
type="button"
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")}
@@ -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";
@@ -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;
}
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
name: "privacy",
href: "/settings/organizations/privacy",
},
{
name: "billing",
href: "/settings/organizations/billing",
},
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
{
name: "SSO",
@@ -173,17 +170,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = [
"privacy",
"billing",
"OAuth Clients",
"SSO",
"directory_sync",
"delegation_credential",
];
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
}
const useTabs = ({
@@ -232,11 +223,27 @@ const useTabs = ({
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
if (isPbacEnabled && permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
});
if (isPbacEnabled) {
if (permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
});
}
if (permissions?.canViewOrganizationBilling) {
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
});
}
} else {
if (isOrgAdminOrOwner) {
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
});
}
}
return {
@@ -272,7 +279,7 @@ const useTabs = ({
if (isAdmin) return true;
return !adminRequiredKeys.includes(tab.name);
});
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
return processTabsMemod;
};
@@ -431,6 +438,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
)}
@@ -438,6 +446,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
{/* Show roles only for sub-teams with PBAC-enabled parent */}
@@ -458,6 +467,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
{/* Hide if there is a parent ID */}
@@ -467,6 +477,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
</>
@@ -475,6 +486,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
</>
@@ -636,6 +648,7 @@ const SettingsSidebarContainer = ({
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
icon="plus"
disableChevron
/>
@@ -737,12 +750,14 @@ const SettingsSidebarContainer = ({
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
<>
@@ -21,7 +21,7 @@ const Page = async () => {
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={true}>
borderInShellHeader={false}>
<BillingView />
</SettingsHeader>
);
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
@@ -45,13 +46,16 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let teamFeatures: Record<number, TeamFeatures> | null = null;
let canViewRoles = false;
let canViewOrganizationBilling = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const [features, rolePermissions] = await Promise.all([
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
const [features, rolePermissions, organizationPermissions] = await Promise.all([
getTeamFeatures(orgId),
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
]);
if (features) {
@@ -62,6 +66,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
// Check if user has permission to read roles
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
canViewRoles = roleActions[CrudAction.Read] ?? false;
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
}
}
@@ -70,7 +76,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles }}
permissions={{ canViewRoles, canViewOrganizationBilling }}
/>
</>
);
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import BillingView from "~/settings/billing/billing-view";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -18,15 +19,17 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrgAdmin();
// TODO(SEAN): Add PBAC to this page in the next PR
await validateUserHasOrgPerms({
permission: "organization.manageBilling",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
return (
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={true}>
borderInShellHeader={false}>
<BillingView />
</SettingsHeader>
);
@@ -1,24 +1,4 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userProfile = session?.user?.profile;
const userId = session?.user?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
if (!isOrgAdminOrOwner) {
return redirect("/settings/organizations/profile");
}
return children;
};
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import type { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrg } from "./validateUserHasOrg";
export const validateUserHasOrgPerms = async ({
redirectTo,
fallbackRoles,
permission,
}: {
redirectTo?: string;
permission: PermissionString;
fallbackRoles: MembershipRole[];
}) => {
const session = await validateUserHasOrg();
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: session.user.id,
teamId: session.user.org.id,
permission,
fallbackRoles,
});
if (!hasPermission) {
redirect(redirectTo || "/settings/my-account/profile");
}
return session;
};
@@ -1,6 +1,16 @@
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { z } from "zod";
import CreateTeamEventType, { LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CreateTeamEventType, LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
@@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/teams/${(await params).id}/event-type`
);
const ServerPage = async () => {
const querySchema = z.object({
id: z.coerce.number().refine((val) => !Number.isNaN(val), {
message: "id must be a string that can be cast to a number",
}),
});
const ServerPage = async ({ params }: ServerPageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const parsed = querySchema.safeParse(await params);
if (!parsed.success) {
notFound();
}
const permissionService = new PermissionCheckService();
const canCreateEventType = await permissionService.checkPermission({
userId: session.user.id,
teamId: parsed.data.id,
permission: "eventType.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
return (
<LayoutWrapper>
<CreateTeamEventType />
<CreateTeamEventType permissions={{ canCreateEventType }} />
</LayoutWrapper>
);
};
@@ -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";
@@ -3,13 +3,15 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -43,6 +45,11 @@ export async function POST(req: NextRequest) {
const { user } = session;
const membershipRepository = new MembershipRepository(prisma);
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id });
const billingPlanService = new BillingPlanService();
const plan = await billingPlanService.getUserPlanByMemberships(memberships);
if (!existingContact.data) {
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
@@ -72,6 +79,7 @@ export async function POST(req: NextRequest) {
sum_of_teams: additionalUserInfo?._count?.teams,
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
sum_of_team_event_types: sumOfTeamEventTypes,
Plan: plan,
},
});
@@ -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) {
@@ -8,8 +8,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { locationsResolver } from "@calcom/app-store/locations";
import NoSSR from "@calcom/lib/components/NoSSR";
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -213,7 +213,6 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
);
const [submit, setSubmit] = useState(false);
const [mounted, setMounted] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
useEffect(() => {
@@ -223,11 +222,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
}
}, [submit, allUpdated, mainForSubmitRef]);
useEffect(() => {
setMounted(true);
}, []);
if (!formPortalRef?.current || !mounted) {
if (!formPortalRef?.current) {
return null;
}
@@ -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";
@@ -185,6 +185,10 @@ function BookingListItem(booking: BookingItemProps) {
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
const paymentAppData = getPaymentAppData(booking.eventType);
const location = booking.location as ReturnType<typeof getEventLocationValue>;
@@ -223,10 +227,7 @@ function BookingListItem(booking: BookingItemProps) {
};
const getSeatReferenceUid = () => {
if (!booking.seatsReferences[0]) {
return undefined;
}
return booking.seatsReferences[0].referenceUid;
return userSeat?.referenceUid;
};
const actionContext: BookingActionContext = {
@@ -250,6 +251,7 @@ function BookingListItem(booking: BookingItemProps) {
booking.location === "integrations:daily" ||
(typeof booking.location === "string" && booking.location.trim() === ""),
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
isAttendee,
cardCharged,
attendeeList,
getSeatReferenceUid,
@@ -21,6 +21,7 @@ export interface BookingActionContext {
isDisabledRescheduling: boolean;
isCalVideoLocation: boolean;
showPendingPayment: boolean;
isAttendee: boolean;
cardCharged: boolean;
attendeeList: Array<{
name: string;
@@ -102,6 +103,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
isDisabledRescheduling,
isBookingFromRoutingForm,
getSeatReferenceUid,
isAttendee,
t,
} = context;
@@ -111,7 +113,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "clock",
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
disabled:
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
@@ -81,7 +81,7 @@ describe("EditLocationDialog", () => {
},
{
value: "integrations:daily",
label: "Cal Video (Global)",
label: "Cal Video (Default)",
disabled: false,
icon: "/app-store/dailyvideo/icon.svg",
slug: "daily-video",
+3
View File
@@ -0,0 +1,3 @@
import { nextJsConfig } from "@calcom/eslint-config/next-js";
export default [...nextJsConfig];
@@ -5,8 +5,8 @@ import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
uid,
seatReferenceUid: maybeSeatReferenceUid,
bookingSeat,
} = await maybeGetBookingUidFromSeat(prisma, bookingUid);
} = await maybeGetBookingUidFromSeat(prisma, seatReferenceUid ? seatReferenceUid : bookingUid);
const booking = await prisma.booking.findUnique({
where: {
@@ -310,7 +310,6 @@ const OnboardingPage = ({
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
formPortalRef.current &&
eventTypeGroups && (
<ConfigureStepCard
slug={appMetadata.slug}
@@ -57,6 +57,9 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
type BookingsProps = {
status: (typeof validStatuses)[number];
userId?: number;
permissions: {
canReadOthersBookings: boolean;
};
};
function useSystemSegments(userId?: number) {
@@ -111,7 +114,7 @@ type RowData =
type: "today" | "next";
};
function BookingsContent({ status }: BookingsProps) {
function BookingsContent({ status, permissions }: BookingsProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -154,7 +157,7 @@ function BookingsContent({ status }: BookingsProps) {
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
"data-testid": tabConfig["data-testid"],
}));
}, [searchParams?.toString()]);
}, [searchParams]);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
@@ -215,7 +218,7 @@ function BookingsContent({ status }: BookingsProps) {
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
@@ -314,7 +317,7 @@ function BookingsContent({ status }: BookingsProps) {
},
}),
];
}, [user, status, t]);
}, [user, status, t, permissions.canReadOthersBookings]);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
@@ -352,7 +355,7 @@ function BookingsContent({ status }: BookingsProps) {
isToday: false,
})) || []
);
}, [query.data]);
}, [query.data, status, user?.timeZone]);
const bookingsToday = useMemo<RowData[]>(() => {
return (
@@ -371,7 +374,7 @@ function BookingsContent({ status }: BookingsProps) {
isToday: true,
})) ?? []
);
}, [query.data]);
}, [query.data, user?.timeZone]);
const finalData = useMemo<RowData[]>(() => {
if (status !== "upcoming") {
@@ -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";
@@ -556,7 +556,9 @@ export default function Success(props: PageProps) {
<div className="font-medium">
{isCancelled ? t("reason") : t("reschedule_reason")}
</div>
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
<div className="col-span-2 mb-6 last:mb-0">
<p className="break-words">{cancellationReason}</p>
</div>
</>
)}
{isCancelled && bookingInfo?.cancelledBy && (
@@ -12,7 +12,10 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import {
CreateEventTypeDialog,
type ProfileOption,
} from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
@@ -846,17 +849,7 @@ const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTe
);
};
const CTA = ({
profileOptions,
}: {
profileOptions: {
teamId: number | null | undefined;
label: string | null;
image: string;
membershipRole: MembershipRole | null | undefined;
slug: string | null;
}[];
}) => {
const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => {
const { t } = useLocale();
if (!profileOptions.length) return null;
@@ -951,7 +944,7 @@ type Props = {
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
const profileOptions =
userEventGroupsData?.profiles
userEventGroupsData.profiles
?.filter((profile) => !profile.readOnly)
?.filter((profile) => !profile.eventTypesLockedByOrg)
?.filter((profile) => {
@@ -973,12 +966,20 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
);
})
?.map((profile) => {
const permissions = profile.teamId
? userEventGroupsData.teamPermissions[profile.teamId]
: {
// always can create eventType on personal level
canCreateEventType: true,
};
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
permissions,
};
}) ?? [];
@@ -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>
@@ -30,16 +30,18 @@ declare global {
export default function PlatformBillingUpgrade() {
const pathname = usePathname();
const { t } = useLocale();
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
useGetUserAttributes();
const returnTo = pathname;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const teamId = `teamId=${userOrgId}`;
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}&${teamId}`;
const onContactSupportClick = async () => {
if (window.Support) {
window.Support.open();
}
};
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
useGetUserAttributes();
const { mutateAsync: removeTeamSubscription, isPending: isRemoveTeamSubscriptionLoading } =
useUnsubscribeTeamToStripe({
@@ -8,11 +8,15 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { WizardLayout } from "@calcom/ui/components/layout";
import { showToast } from "@calcom/ui/components/toast";
import { Button } from "@calcom/ui/components/button";
export const CreateTeamEventType = () => {
type CreateTeamEventTypeProps = {
permissions: { canCreateEventType: boolean };
};
export const CreateTeamEventType = ({ permissions }: CreateTeamEventTypeProps) => {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
@@ -53,7 +57,7 @@ export const CreateTeamEventType = () => {
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={true}
permissions={permissions}
urlPrefix={urlPrefix}
isPending={createMutation.isPending}
form={form}
@@ -82,5 +86,3 @@ export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
</WizardLayout>
);
};
export default CreateTeamEventType;
+3 -2
View File
@@ -426,6 +426,7 @@ export default function Signup({
id="signup-email"
{...register("email")}
label={t("email")}
placeholder="john@doe.com"
type="email"
autoComplete="email"
disabled={prepopulateFormValues?.email}
@@ -522,8 +523,8 @@ export default function Signup({
usernameTaken
}>
{premiumUsername && !usernameTaken
? `${t("create_account")} (${getPremiumPlanPriceValue()})`
: t("create_account")}
? `${t("get_started")} (${getPremiumPlanPriceValue()})`
: t("get_started")}
</Button>
)}
</Form>
+10 -6
View File
@@ -2,7 +2,6 @@
import { useState } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberList from "@calcom/features/ee/teams/components/MemberList";
@@ -23,18 +22,23 @@ interface TeamMembersViewProps {
}[];
}[];
};
attributes?: any[];
attributes?: {
id: string;
name: string;
options: {
value: string;
}[];
}[];
permissions: MemberPermissions;
}
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
const [_showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
// Use PBAC permissions if available, otherwise fall back to role-based check
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
// Use PBAC permissions - server-side permission check should be done in parent component
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? false;
return (
<LicenseRequired>
+17 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.7.2",
"version": "5.7.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -20,8 +20,8 @@
"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",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
"translate-locales": "ts-node scripts/check-missing-translations.ts",
@@ -32,20 +32,20 @@
},
"dependencies": {
"@boxyhq/saml-jackson": "1.52.2",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/dayjs": "*",
"@calcom/app-store": "workspace:*",
"@calcom/app-store-cli": "workspace:*",
"@calcom/dayjs": "workspace:*",
"@calcom/embed-core": "workspace:*",
"@calcom/embed-react": "workspace:*",
"@calcom/embed-snippet": "workspace:*",
"@calcom/features": "*",
"@calcom/lib": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-types": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
"@calcom/tsconfig": "*",
"@calcom/ui": "*",
"@calcom/features": "workspace:*",
"@calcom/lib": "workspace:*",
"@calcom/platform-enums": "workspace:*",
"@calcom/platform-types": "workspace:*",
"@calcom/prisma": "workspace:*",
"@calcom/trpc": "workspace:*",
"@calcom/tsconfig": "workspace:*",
"@calcom/ui": "workspace:*",
"@daily-co/daily-js": "^0.83.1",
"@daily-co/daily-react": "^0.23.2",
"@dub/analytics": "^0.0.27",
@@ -158,8 +158,9 @@
},
"devDependencies": {
"@babel/core": "^7.19.6",
"@calcom/config": "*",
"@calcom/types": "*",
"@calcom/config": "workspace:*",
"@calcom/eslint-config": "workspace:*",
"@calcom/types": "workspace:*",
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
"@playwright/test": "^1.45.3",
"@testing-library/react": "^13.3.0",
@@ -0,0 +1,51 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Booking Filters", () => {
test("Member role should not see the member filter", async ({ page, users }) => {
const teamMateName = "team mate 1";
await users.create(undefined, {
hasTeam: true,
isOrg: true,
teammates: [{ name: teamMateName }],
});
const allUsers = await users.get();
const memberUser = allUsers.find((user) => user.name === teamMateName);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!memberUser) {
throw new Error("user should exist");
}
await memberUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeHidden();
});
test("Admin role should see the member filter", async ({ page, users }) => {
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
});
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
});
});
+123
View File
@@ -458,5 +458,128 @@ test.describe("Reschedule for booking with seats", () => {
await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1);
});
test("Host reschedule from /upcoming page should have rescheduleUid parameter set to bookingUid", async ({
page,
users,
bookings,
}) => {
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await user.apiLogin();
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
await page.goto("/bookings/upcoming");
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const rescheduleUid = url.searchParams.get("rescheduleUid");
return !!rescheduleUid && rescheduleUid === booking.uid;
});
});
test("Second attendee reschedule from /upcoming page should use correct seatReferenceUid and show attendee info", async ({
page,
users,
bookings,
}) => {
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
orderBy: { id: "asc" },
});
const secondUser = await users.create({
name: "Jane Second",
email: "second+seats@cal.com",
});
await secondUser.apiLogin();
await page.goto("/bookings/upcoming");
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const rescheduleUid = url.searchParams.get("rescheduleUid");
return !!rescheduleUid && rescheduleUid === references[1].referenceUid;
});
await selectFirstAvailableTimeSlotNextMonth(page);
const nameElement = page.locator("input[name=name]");
const name = await nameElement.inputValue();
expect(name).toBe("Jane Second");
const emailElement = page.locator("input[name=email]");
const email = await emailElement.inputValue();
expect(email).toBe("second+seats@cal.com");
// Complete the reschedule
await confirmReschedule(page);
// Verify successful reschedule
await page.waitForURL(/\/booking\/.*/);
await expect(page).toHaveURL(/\/booking\/.*/);
});
// @TODO: force 404 when rescheduleUid is not found
});
+43 -32
View File
@@ -60,7 +60,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
@@ -98,7 +97,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "floating-popup",
@@ -136,7 +134,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "element-click",
@@ -176,7 +173,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
@@ -234,7 +230,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
@@ -275,7 +270,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "floating-popup",
@@ -315,7 +309,6 @@ test.describe("Embed Code Generator Tests", () => {
});
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "element-click",
@@ -333,8 +326,7 @@ function chooseEmbedType(page: Page, embedType: EmbedType) {
}
async function goToReactCodeTab(page: Page) {
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
// To prevent early timeo
await page.waitForTimeout(1000);
await page.locator("[data-testid=horizontal-tab-react]").click();
}
@@ -343,7 +335,7 @@ async function clickEmbedButton(page: Page) {
const embedButton = page.locator("[data-testid=embed]");
const embedUrl = await embedButton.getAttribute("data-test-embed-url");
embedButton.click();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return embedUrl!;
}
@@ -439,18 +431,24 @@ async function expectValidHtmlEmbedSnippet(
}
function assertThatCodeIsValidVanillaJsCode(code: string) {
const lintResult = linter.verify(code, {
env: {
browser: true,
const lintResult = linter.verify(code, [
{
languageOptions: {
ecmaVersion: 2021,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
globals: {
window: "readonly",
document: "readonly",
navigator: "readonly",
Cal: "readonly",
console: "readonly",
},
},
rules: eslintRules,
},
parserOptions: {
ecmaVersion: 2021,
},
globals: {
Cal: "readonly",
},
rules: eslintRules,
});
]);
if (lintResult.length) {
console.log(
JSON.stringify({
@@ -459,23 +457,35 @@ function assertThatCodeIsValidVanillaJsCode(code: string) {
})
);
}
expect(lintResult.length).toBe(0);
}
function assertThatCodeIsValidReactCode(code: string) {
const lintResult = linter.verify(code, {
env: {
browser: true,
},
parserOptions: {
ecmaVersion: 2021,
ecmaFeatures: {
jsx: true,
const lintResult = linter.verify(code, [
{
languageOptions: {
ecmaVersion: 2021,
sourceType: "module",
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
window: "readonly",
document: "readonly",
navigator: "readonly",
console: "readonly",
},
},
rules: {
...eslintRules,
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off",
semi: "off",
},
sourceType: "module",
},
rules: eslintRules,
});
]);
if (lintResult.length) {
console.log(
JSON.stringify({
@@ -484,6 +494,7 @@ function assertThatCodeIsValidReactCode(code: string) {
})
);
}
expect(lintResult.length).toBe(0);
}
+11 -2
View File
@@ -239,7 +239,7 @@ test.describe("Event Types tests", () => {
await gotoFirstEventType(page);
await page.getByTestId("location-select").click();
await page.locator(`text="Cal Video (Global)"`).click();
await page.locator(`text="Cal Video (Default)"`).click();
await saveEventType(page);
await gotoBookingPage(page);
@@ -282,7 +282,7 @@ test.describe("Event Types tests", () => {
await selectAttendeePhoneNumber(page);
// Add Cal Video location
await addAnotherLocation(page, "Cal Video (Global)");
await addAnotherLocation(page, "Cal Video (Default)");
await saveEventType(page);
@@ -496,6 +496,9 @@ test.describe("Event Types tests", () => {
});
await test.step("should open first eventType and check Interface Language", async () => {
await gotoFirstEventType(page);
// Go to Advanced tab
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=event-interface-language-toggle]");
const interfaceLanguageValue = page
.getByTestId("event-interface-language")
.locator('div[class$="-singleValue"]');
@@ -518,6 +521,9 @@ test.describe("Event Types tests", () => {
await test.step("should open first eventType and change Interface Language to Deutsche", async () => {
await gotoFirstEventType(page);
// Go to Advanced tab and enable offerSeats
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=event-interface-language-toggle]");
await page.getByTestId("event-interface-language").click();
await page.locator(`text="Deutsch"`).click();
await saveEventType(page);
@@ -574,6 +580,9 @@ test.describe("Event Types tests", () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await gotoFirstEventType(page);
// Go to Advanced tab and enable offerSeats
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=event-interface-language-toggle]");
await page.getByTestId("event-interface-language").click();
await page.getByTestId("select-option-es").click();
await saveEventType(page);
+1 -1
View File
@@ -198,7 +198,7 @@ export async function expectSegmentCleared(page: Page) {
// Check that no segment is selected (button shows default text)
const segmentSelect = page.getByTestId("filter-segment-select");
const buttonText = await segmentSelect.textContent();
expect(buttonText?.trim()).toEqual("Segment");
expect(buttonText?.trim()).toEqual("Saved filters");
}
/**

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