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
345 changed files with 8261 additions and 33290 deletions
-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
+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 -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 }),
]);
-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()
@@ -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")}
@@ -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,9 +19,11 @@ 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
@@ -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>
);
};
@@ -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,
},
});
@@ -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;
}
@@ -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 { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
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") {
@@ -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,
};
}) ?? [];
@@ -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.3",
"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");
}
/**
@@ -199,6 +199,7 @@ export function createBookingPageFixture(page: Page) {
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
},
getAITranslationToggleDisabled: async () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
timeout: 5000,
state: "attached",
@@ -207,6 +208,7 @@ export function createBookingPageFixture(page: Page) {
return (await toggle.getAttribute("disabled")) !== null;
},
toggleAITranslation: async () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.getByTestId("ai_translation_toggle").click();
},
updateEventTypeDescription: async (description: string) => {
@@ -81,10 +81,13 @@ test.describe("Managed Event Types", () => {
await page.goto(`/event-types/${managedEvent.id}?tabName=setup`);
await expect(page.getByTestId("vertical-tab-basics")).toHaveAttribute("aria-current", "page"); // fix the race condition
await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); //fix the race condition
await page.locator("#location-select").click();
const optionText = await getByKey(page, "organizer_default_conferencing_app");
await expect(optionText).toBeVisible();
await optionText.click();
const targetText = (await localize("en"))("organizer_default_conferencing_app");
const options = page.locator('[data-testid="location-select-item-conferencing"]');
const targetOption = options.locator(`text=${targetText}`);
await expect(targetOption).toBeVisible();
await targetOption.click();
await saveAndWaitForResponse(page);
await page.getByTestId("vertical-tab-assignment").click();
@@ -190,7 +193,7 @@ test.describe("Managed Event Types", () => {
});
const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise<Locator> }[] = [
{ slug: "setup", locator: (page) => getByKey(page, "translate_description_button") },
{ slug: "setup", locator: (page) => getByKey(page, "allow_multiple_durations") },
{
slug: "team",
locator: (page) => getByKey(page, "automatically_add_all_team_members"),
+1 -1
View File
@@ -209,7 +209,7 @@ test.describe("Email Signup Flow Test", async () => {
data: {
identifier: userToCreate.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
create: {
name: "Rick's Team",
+23 -7
View File
@@ -321,6 +321,7 @@
"secret_meeting": "Secret Meeting",
"login_instead": "Login instead",
"create_account": "Create Account",
"get_started": "Get started",
"confirm_password": "Confirm password",
"reset_your_password": "Set your new password with the instructions sent to your email address.",
"org_banner_instructions": "Please upload image of {{width}} width and {{height}} height.",
@@ -958,7 +959,7 @@
"light_event_type_color": "Event Type Color (Light Theme)",
"dark_event_type_color": "Event Type Color (Dark Theme)",
"file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create Team",
"create_team": "Create team",
"name": "Name",
"nameless_team": "Nameless Team",
"oauth_clients": "OAuth Clients",
@@ -1019,6 +1020,7 @@
"attendees": "Attendees",
"add_attendees": "Add attendees",
"show_advanced_settings": "Show advanced settings",
"hide_advanced_settings": "Hide advanced settings",
"event_name": "Event Name",
"event_name_in_calendar": "Calendar event name",
"event_name_tooltip": "The name that will appear in calendars",
@@ -1231,7 +1233,7 @@
"contact_sales": "Contact Sales",
"error_404": "Error 404",
"default": "Default",
"set_to_default": "Set to Default",
"set_to_default": "Set as Default",
"new_schedule_btn": "New schedule",
"add_new_schedule": "Add a new schedule",
"add_new_calendar": "Add a new calendar",
@@ -1329,7 +1331,7 @@
"make_team_private_description": "Your team members won't be able to see other team members when this is turned on.",
"you_cannot_see_team_members": "You cannot see all the team members of a private team.",
"you_cannot_see_teams_of_org": "You cannot see teams of a private organization.",
"allow_booker_to_select_duration": "Allow booker to select from multiple durations",
"allow_multiple_durations": "Allow multiple durations",
"impersonate_user_tip": "All uses of this feature is audited.",
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "Click here to stop",
@@ -2689,7 +2691,7 @@
"enterprise": "Enterprise",
"enterprise_description": "Upgrade to Enterprise to create your Organization",
"create_your_org": "Create your Organization",
"create_org": "Create Organization",
"create_org": "Create organization",
"create_your_org_description": "Upgrade to Organizations and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"create_your_enterprise_description": "Upgrade to Enterprise and get access to Active Directory Sync, SCIM Automatic User provisioning, Cal.ai Voice Agents, Admin APIs and more!",
"other_payment_app_enabled": "You can only enable one payment app per event type",
@@ -2923,6 +2925,7 @@
"proceeding_agreement": "By proceeding, you agree to our",
"terms": "Terms",
"always_show_x_days": "Always {{x}} days available",
"always_show_x_days_description": "Automatically moves the {{x}}-day booking window forward each day, ensuring your calendar always shows the next {{x}} days of available slots.",
"team_subscription_cancelled_successfully": "Team subscription cancelled successfully",
"team_subscription_cancellation_error": "Error while cancelling the team subscription",
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
@@ -3144,7 +3147,8 @@
"filter_operator_ends_with": "Ends with",
"filter_operator_is_empty": "Is empty",
"filter_operator_not_empty": "Not empty",
"translate_description_button": "Translate title/description to the visitor's browser language using AI",
"translate_description_button": "Auto translate title and description",
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"rr_distribution_method_availability_title": "Maximize availability",
@@ -3310,6 +3314,7 @@
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
"segment": "Segment",
"saved_filters": "Saved filters",
"rename": "Rename",
"rename_segment": "Rename Segment",
"delete_segment": "Delete Segment",
@@ -3339,6 +3344,7 @@
"picklist": "Picklist",
"most_cancelled_bookings": "Most Cancelled",
"interface_language": "Interface Language",
"interface_language_description": "Set your preferred language for the booking interface",
"visitors_browser_language": "Visitor's browser language",
"name_or_email": "Name or Email",
"salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists",
@@ -3510,10 +3516,20 @@
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_resource_webhook": "Webhook",
"pbac_resource_availability": "Availability",
"pbac_resource_out_of_office": "Out of Office",
"pbac_desc_create_webhooks": "Create webhooks",
"pbac_desc_view_webhooks": "View webhooks",
"pbac_desc_update_webhooks": "Update webhooks",
"pbac_desc_delete_webhooks": "Delete webhooks",
"pbac_desc_create_availability": "Create availability",
"pbac_desc_view_availability": "View availability",
"pbac_desc_update_availability": "Update availability",
"pbac_desc_delete_availability": "Delete availability",
"pbac_desc_create_out_of_office": "Create out of office",
"pbac_desc_view_out_of_office": "View out of office",
"pbac_desc_update_out_of_office": "Update out of office",
"pbac_desc_delete_out_of_office": "Delete out of office",
"pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
@@ -3561,8 +3577,8 @@
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
"round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time",
"meeting_start_time": "Meeting Start Time",
"booking_creation_time": "Booking creation time",
"meeting_start_time": "Meeting start time",
"members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"download_expense_log": "Download Expense Log",
+5
View File
@@ -647,3 +647,8 @@ select:focus {
[data-radix-popper-content-wrapper] {
border: none;
}
html[dir="rtl"] .react-tel-input .flag-dropdown {
left: auto !important;
right: 0 !important;
}
+15 -10
View File
@@ -1,6 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import {
@@ -9,6 +7,13 @@ import {
} from "@calcom/lib/intervalLimits/server/checkDurationLimits";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
const mockGetTotalBookingDuration = vi.fn();
vi.mock("@calcom/lib/server/repository/booking", () => ({
BookingRepository: vi.fn().mockImplementation(() => ({
getTotalBookingDuration: mockGetTotalBookingDuration,
})),
}));
type MockData = {
id: number;
startDate: Date;
@@ -22,19 +27,19 @@ const MOCK_DATA: MockData = {
// Path: apps/web/test/lib/checkDurationLimits.ts
describe("Check Duration Limits Tests", () => {
it("Should return no errors if limit is not reached", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 0 }]);
mockGetTotalBookingDuration.mockResolvedValue(0);
await expect(
checkDurationLimits({ PER_DAY: 60 }, MOCK_DATA.startDate, MOCK_DATA.id)
).resolves.toBeTruthy();
});
it("Should throw an error if limit is reached", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
mockGetTotalBookingDuration.mockResolvedValue(60);
await expect(
checkDurationLimits({ PER_DAY: 60 }, MOCK_DATA.startDate, MOCK_DATA.id)
).rejects.toThrowError();
});
it("Should pass with multiple duration limits", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 30 }]);
mockGetTotalBookingDuration.mockResolvedValue(30);
await expect(
checkDurationLimits(
{
@@ -47,7 +52,7 @@ describe("Check Duration Limits Tests", () => {
).resolves.toBeTruthy();
});
it("Should pass with multiple duration limits with one undefined", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 30 }]);
mockGetTotalBookingDuration.mockResolvedValue(30);
await expect(
checkDurationLimits(
{
@@ -60,7 +65,7 @@ describe("Check Duration Limits Tests", () => {
).resolves.toBeTruthy();
});
it("Should return no errors if limit is not reached with multiple bookings", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
mockGetTotalBookingDuration.mockResolvedValue(60);
await expect(
checkDurationLimits(
{
@@ -73,7 +78,7 @@ describe("Check Duration Limits Tests", () => {
).resolves.toBeTruthy();
});
it("Should throw an error if one of the limit is reached with multiple bookings", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 90 }]);
mockGetTotalBookingDuration.mockResolvedValue(90);
await expect(
checkDurationLimits(
{
@@ -90,7 +95,7 @@ describe("Check Duration Limits Tests", () => {
// Path: apps/web/test/lib/checkDurationLimits.ts
describe("Check Duration Limit Tests", () => {
it("Should return no busyTimes and no error if limit is not reached", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
mockGetTotalBookingDuration.mockResolvedValue(60);
await expect(
checkDurationLimit({
key: "PER_DAY",
@@ -143,9 +143,10 @@ describe("handleChildrenEventTypes", () => {
profileId: null,
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...evType,
...expectedEvType,
parentId: 1,
users: { connect: [{ id: 4 }] },
lockTimeZoneToggleOnBookingPage: false,
@@ -206,7 +207,7 @@ describe("handleChildrenEventTypes", () => {
bookingLimits: undefined,
},
});
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
const { profileId, autoTranslateDescriptionEnabled, createdAt, updatedAt, ...rest } = evType;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
@@ -317,9 +318,10 @@ describe("handleChildrenEventTypes", () => {
profileId: null,
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...evType,
...expectedEvType,
parentId: 1,
users: { connect: [{ id: 4 }] },
bookingLimits: undefined,
@@ -383,7 +385,7 @@ describe("handleChildrenEventTypes", () => {
length: 30,
},
});
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
const { profileId, autoTranslateDescriptionEnabled, createdAt, updatedAt, ...rest } = evType;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
@@ -464,7 +466,11 @@ describe("handleChildrenEventTypes", () => {
...evType,
};
prismaMock.eventType.update.mockResolvedValue(mockUpdatedEventType);
prismaMock.eventType.update.mockResolvedValue({
...mockUpdatedEventType,
createdAt: new Date(),
updatedAt: new Date(),
});
await updateChildrenEventTypes({
eventTypeId: 1,
@@ -480,9 +486,10 @@ describe("handleChildrenEventTypes", () => {
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...evType,
...expectedEvType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
@@ -507,7 +514,7 @@ describe("handleChildrenEventTypes", () => {
allowReschedulingCancelledBookings: false,
},
});
const { profileId, rrSegmentQueryValue, ...rest } = evType;
const { profileId, rrSegmentQueryValue, createdAt: _, updatedAt: __, ...rest } = evType;
if ("workflows" in rest) delete rest.workflows;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
@@ -1534,6 +1534,7 @@ export function getOrganizer({
completedOnboarding,
username,
locked,
emailVerified,
}: {
name: string;
email: string;
@@ -1551,6 +1552,7 @@ export function getOrganizer({
completedOnboarding?: boolean;
username?: string;
locked?: boolean;
emailVerified?: Date | null;
}) {
username = username ?? TestData.users.example.username;
return {
@@ -1572,7 +1574,8 @@ export function getOrganizer({
smsLockState,
completedOnboarding,
locked,
};
emailVerified,
};
}
export function getScenarioData(
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -213,7 +213,8 @@
"platform/atoms/event-type",
"platform/atoms/calendar-settings",
"platform/atoms/payment-form",
"platform/atoms/conferencing-apps"
"platform/atoms/conferencing-apps",
"platform/atoms/calendar-view"
]
},
{
+61
View File
@@ -0,0 +1,61 @@
---
title: "Calendar view"
---
The calendar view atom is basically a weekly calendar view wherein users can see entire week at a glance, clearly showing both available and unavailable time slots. This view is designed to provide a comprehensive and easy-to-read schedule, helping users quickly identify open slots for booking or planning.
## Individual event type
Below code snippet can be used to render the calendar view atom for an individual event
```js
import { CalendarView } from "@calcom/atoms";
export default function Booker( props : BookerProps ) {
return (
<div>
<CalendarView username={props.calUsername} eventSlug={props.eventSlug} />
</div>
)
}
```
## Team event type
Below code snippet can be used to render the calendar view atom for a team event
```js
import { CalendarView } from "@calcom/atoms";
export default function Booker( props : BookerProps ) {
return (
<div>
<CalendarView isTeamEvent={true} teamId={props.teamId} eventSlug={props.eventSlug} />
</div>
)
}
```
For a demonstration of the calendar view atom, please refer to the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/40ebab9efaa149019ea487e35ff8f656?sid=c6a748c3-0564-4400-ac7c-a17a404c9af6"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
Below is a list of props that can be passed to the create event type atom
| Name | Required | Description |
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
| username | Yes | Username of the person whose schedule is to be displayed |
| eventSlug | Yes | Unique slug created for a particular event | |
| isTeamEvent | No | Boolean indicating if it is a team event, to be passed only for team events |
| teamId | No | The id of the team for which the event is created, to be passed only for team events |
+1 -1
View File
@@ -332,7 +332,7 @@ Below video shows a demonstration of the all the options you get in the advanced
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/e0121b51920544eea92251dba99245fa?sid=ac627de7-5673-41b4-aa78-c6c9e439bdb5"
src="https://www.loom.com/embed/7a03c4a453ca401ab787b2db98728f31?sid=8cdc6398-0017-41c8-a0a1-8fa31f80616c"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
+3
View File
@@ -0,0 +1,3 @@
import { config } from "@calcom/eslint-config/base";
export default config;
@@ -0,0 +1,3 @@
import { nextJsConfig } from "@calcom/eslint-config/next-js";
export default nextJsConfig;
+3 -4
View File
@@ -6,10 +6,11 @@
"dev": "PORT=5100 next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"dependencies": {
"@calcom/atoms": "*",
"@calcom/atoms": "workspace:*",
"@calcom/eslint-config": "workspace:*",
"@prisma/client": "6.7.0",
"next": "14.0.4",
"prisma": "^6.7.0",
@@ -22,8 +23,6 @@
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"dotenv": "^16.3.1",
"eslint": "^8.34.0",
"eslint-config-next": "14.0.4",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^4.9.4"
+10 -9
View File
@@ -8,6 +8,7 @@
"packages/*",
"packages/embeds/*",
"packages/features/*",
"packages/app-store",
"packages/app-store/*",
"packages/platform/*",
"packages/platform/examples/base",
@@ -53,9 +54,9 @@
"env-check:common": "dotenv-checker --schema .env.example --env .env",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"heroku-postbuild": "turbo run @calcom/web#build",
"lint:fix": "turbo run lint:fix",
"lint:report": "turbo run lint:report",
"lint": "turbo run lint",
"lint:fix": "turbo lint:fix",
"lint:report": "turbo lint:report",
"lint": "turbo lint",
"postinstall": "husky install && turbo run post-install",
"pre-commit": "lint-staged",
"predev": "echo 'Checking env files'",
@@ -71,8 +72,8 @@
"test-e2e:embed": "yarn db-seed && yarn e2e:embed",
"test-e2e:embed-react": "yarn db-seed && yarn e2e:embed-react",
"test-playwright": "yarn playwright test --config=playwright.config.ts",
"test": "vitest run",
"test:ui": "vitest --ui",
"test": "TZ=UTC vitest run",
"test:ui": "TZ=UTC vitest --ui",
"type-check": "turbo run type-check",
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
"web": "yarn workspace @calcom/web",
@@ -84,6 +85,7 @@
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
},
"devDependencies": {
"@calcom/eslint-config": "workspace:*",
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "2.29.4",
"@faker-js/faker": "9.2.0",
@@ -96,7 +98,7 @@
"c8": "^7.13.0",
"checkly": "latest",
"dotenv-checker": "^1.1.5",
"eslint-plugin-import": "^2.32.0",
"eslint": "9.36.0",
"husky": "^8.0.0",
"i18n-unused": "^0.13.0",
"jest-diff": "^29.5.0",
@@ -110,6 +112,7 @@
"prismock": "1.35.3",
"resize-observer-polyfill": "^1.5.1",
"tsc-absolute": "^1.0.0",
"turbo": "^2.5.5",
"typescript": "5.9.0-beta",
"vitest": "^2.1.9",
"vitest-fetch-mock": "^0.3.0",
@@ -122,9 +125,7 @@
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^2.5.5"
"p-limit": "^6.2.0"
},
"resolutions": {
"types-ramda": "0.29.4",
+1 -1
View File
@@ -18,7 +18,7 @@
"dist/cli.js"
],
"dependencies": {
"@calcom/lib": "*",
"@calcom/lib": "workspace:*",
"ink": "^3.2.0",
"ink-select-input": "^4.2.1",
"ink-text-input": "^4.0.3",
+2 -2
View File
@@ -5,7 +5,7 @@
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*",
"@calcom/lib": "workspace:*",
"@getalby/lightning-tools": "^5.1.2",
"@getalby/sdk": "^4.0.0",
"@webbtc/webln-types": "^3.0.0",
@@ -13,7 +13,7 @@
"svix": "^0.85.1"
},
"devDependencies": {
"@calcom/types": "*"
"@calcom/types": "workspace:*"
},
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r"
}
+2 -2
View File
@@ -6,9 +6,9 @@
"main": "./index.ts",
"description": "The joyful productivity app\r\r",
"dependencies": {
"@calcom/lib": "*"
"@calcom/lib": "workspace:*"
},
"devDependencies": {
"@calcom/types": "*"
"@calcom/types": "workspace:*"
}
}
@@ -6,10 +6,10 @@
"main": "./index.ts",
"description": "Apple calendar runs both the macOS and iOS mobile operating systems. Offering online cloud backup of calendars using Apple’s iCloud service, it can sync with Google Calendar and Microsoft Exchange Server. Users can schedule events in their day that include time, location, duration, and extra notes.",
"dependencies": {
"@calcom/prisma": "*",
"@calcom/prisma": "workspace:*",
"react-hook-form": "^7.43.3"
},
"devDependencies": {
"@calcom/types": "*"
"@calcom/types": "workspace:*"
}
}

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