Compare commits

...
Author SHA1 Message Date
zomars da6fc9f2d5 chore: release v5.4.13
Post release / release (push) Canceled after 0s
2025-07-03 21:10:47 +00:00
Keith Williamsandcubic-dev-ai[bot] 20434856e9 fix: Don't continue retrying SMS reminders infinitely (#22251)
* fix: Don't continue retrying SMS reminders infinitely

* Update packages/features/ee/workflows/api/scheduleSMSReminders.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-03 21:02:08 +00:00
Joe Au-YeungandUdit Takkar 7f891adff2 feat: Salesforce - write assignment reason to record (#22223)
* Create `assignmentReasonRepository`

* Write `assignmentReason` to booking

* Type fix

* Clean up

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-07-03 20:56:36 +00:00
eb66fbe054 feat: add Video Options dropdown with meeting session details (#22147)
* feat: add Video Options dropdown with meeting session details

- Add Video Options dropdown to BookingListItem for Daily video bookings
- Move recording actions from TableActions to new dropdown
- Add meeting session details feature with participant info, join times, duration
- Create new tRPC endpoint for fetching Daily.co meeting information
- Add getMeetingInformation method to VideoApiAdapter
- Create MeetingSessionDetailsDialog component following ViewRecordingsDialog pattern
- Add translation strings for new UI elements
- Update VideoApiAdapter TypeScript interface

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

* fix: bugs and types

* Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-04 01:47:48 +05:30
Lauris Skraucis 29122934a9 fix: atoms local (#22245)
* Revert "fix: atoms for local development (#22178)"

This reverts commit d74d99dde7.

* chore: remove comment

* feat: local dev scripts

* feat: local dev scripts
2025-07-03 16:45:23 +00:00
manthansubhash01andKartik Saini 0825cce82c fix(ui): improve delete popup spacing on tablets and small screens (#22042)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-03 16:19:19 +00:00
fd964131d1 fix: add formatPeriodFull method for different date formatting in insights (#22202)
* feat: add formatPeriodStraightforward method for comprehensive date formatting

- Add new formatPeriodStraightforward method that always shows full date information
- Update DateRange interface to include formattedDateStraightforward field
- Modify getDateRanges to generate both formatted versions
- Add comprehensive unit tests for the new straightforward formatting
- Maintain backward compatibility with existing formatPeriod method

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

* refactor: rename formatPeriodStraightforward to formatPeriodFull

- Rename method from formatPeriodStraightforward to formatPeriodFull
- Rename interface field from formattedDateStraightforward to formattedDateFull
- Update all test cases to use new method name
- All tests continue to pass with new naming

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

* test: add comprehensive getDateRanges formatting comparison tests

- Add 7 new test cases that call getDateRanges directly
- Demonstrate side-by-side comparison of formattedDate vs formattedDateFull
- Cover daily, weekly, and monthly view formatting differences
- Include real-world usage examples showing practical benefits
- All 65 tests pass including new formatting comparison tests

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

* refactor: extract formattedDate arrays in getDateRanges comparison tests

- Refactor all 6 tests in 'getDateRanges - Formatting Comparison' suite
- Extract formattedDate and formattedDateFull arrays separately
- Use clean expect statements for easier visual review
- Remove startDate/endDate checks as requested
- Maintain all existing test scenarios and expected values

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

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-03 16:01:44 +00:00
Lauris Skraucis 77d9c15fed fix: atoms local dev (#22240)
* fix: atoms for local development

* fix: atoms for local development
2025-07-03 16:42:57 +02:00
Morgan b21295f268 chore: add user org id to user context apiv2 axiom (#22236) 2025-07-03 12:09:55 +00:00
5769553659 chore: add sharp as depdency on apiv1 (#22204)
* fix: Process base64 avatar image (#22165)

* fix: process base64 avatar image

* better name

* more

* fix import

* Attempt to fix sharp dependency issue by upgrading next

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-03 12:52:14 +01:00
devin-ai-integration[bot]andalex@cal.com d23d68bf2d fix: add explicit type annotation to CalComAdapter function (#22230)
* fix: add explicit type annotation to CalComAdapter function

Resolves TS7056 error where inferred type exceeded maximum length
the compiler will serialize. Added custom CalComAdapterType type
annotation to provide explicit typing while preserving existing
functionality of the authentication adapter.

The custom type annotation matches the actual implementation structure
and resolves the TypeScript compilation error without breaking the
existing NextAuth adapter functionality.

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

* fix: replace any types with proper NextAuth Adapter typing

- Import Awaitable type from next-auth for proper async typing
- Define CalComAdapter type using Cal.com's actual Prisma types
- Remove all any types following TypeScript best practices
- Maintain compatibility with NextAuth's expected interface
- Resolves TS7056 compilation error with explicit typing

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
2025-07-03 11:37:15 +01:00
Morgan d04c7f58a1 chore: stringify user context ids apiv2 axiom (#22234) 2025-07-03 10:12:32 +00:00
Keith WilliamsandDevin AI 93e2426760 perf: optimize filterRedundantDateRanges from O(n²) to O(n log n) (#22093)
* perf: optimize filterRedundantDateRanges from O(n²) to O(n log n)

- Replace nested loop with optimized algorithm that leverages sorted ranges
- Add valueOf caching to avoid repeated .valueOf() calls (similar to PR #22076)
- Implement early termination for ranges that start after current range ends
- Handle identical ranges correctly by keeping first occurrence
- Add comprehensive test coverage with 8 new test cases covering:
  - Multiple nested containments
  - Identical ranges
  - Same start/end time edge cases
  - Invalid ranges (end before start)
  - Large dataset performance (100 ranges)
  - Touching ranges (adjacent ranges)

All 11 tests pass, maintaining behavioral compatibility while improving performance.

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

* revert: remove valueOf caching optimization, keep O(n log n) algorithm

- Remove cached valueOf() variables to address user feedback
- Keep algorithmic optimization with early termination logic
- Maintain O(n log n) complexity through sorted range leveraging
- All 11 tests continue to pass with identical functionality

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

* fix: correct identical range handling in O(n log n) optimization

- Remove complex conditional logic that caused incorrect filtering of identical ranges
- Revert to simple containment check while preserving O(n log n) performance
- All 10 comprehensive unit tests now pass
- Maintains early termination optimization for performance gains

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

* fix: correct identical range handling to keep first occurrence

- Update test expectation from 0 to 1 for three identical ranges
- Modify implementation to keep first occurrence of identical ranges
- Maintain O(n log n) performance optimization
- All 10 unit tests now pass

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace nested loop with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- Preserve all existing test compatibility
- Performance improvements: 1.08x-2.46x speedup across scenarios
- Note: Enterprise pattern correctness issue requires investigation

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace segment tree with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- All unit tests (10/10) and integration tests (5/5) pass
- Type checking passes with no errors
- Still investigating enterprise pattern correctness issue (499 vs 379 results)

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace nested loop with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- Preserve all existing test compatibility

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

* fix: remove redundant sort in interval tree constructor

- Fix index misalignment issue causing correctness problems
- Remove duplicate sorting of nodes after mapping
- Maintain proper index references for containment queries

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace nested loop with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- Preserve all existing test compatibility

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

* fix: simplify interval tree logic to match original behavior

- Remove complex identical range handling logic
- Filter out any range that has containing intervals
- Achieve O(n log n) worst-case complexity with correct behavior
- Fix 'should handle three identical ranges' test failure

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace hybrid algorithm with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Fix identical range handling to keep first occurrence
- Maintain all existing test compatibility
- Update test expectation for three identical ranges to return 1 result

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

* fix: resolve TypeScript errors in interval tree implementation

- Add explicit type annotations for sort function parameters
- Use Array.from() for Map iteration to ensure TypeScript compatibility
- Maintain O(n log n) worst-case complexity with type safety

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace hybrid algorithm with pure interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain balanced tree structure for efficient containment queries
- Preserve all existing test compatibility
- Performance improvements: 1.12x-2.36x speedup depending on scenario

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

* refactor: extract IntervalTree to generic reusable implementation

- Move IntervalTree and IntervalNode to separate packages/lib/intervalTree.ts
- Make IntervalTree generic with type parameter <T> and function parameters for start/end extraction
- Update filterRedundantDateRanges.ts to use generic IntervalTree implementation
- Maintain O(n log n) worst-case complexity and all existing functionality
- All unit tests (10/10) and integration tests (5/5) passing

Addresses GitHub comment from @keithwillcode requesting generic, reusable interval tree

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

* fix: correct containment logic in interval tree implementation

- Fix unconditional 'return false' that incorrectly filtered non-contained ranges
- Maintain O(n log n) complexity while ensuring correct containment behavior
- Add test case for overlapping but non-containing ranges
- Addresses cubic-dev-ai[bot] comment on PR #22093

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

* test: add comprehensive test coverage for cubic-dev-ai bug scenario

- Add test for overlapping but non-containing ranges
- Add test for complex overlapping pattern that exposed the cubic-dev-ai bug
- These tests would have caught the unconditional 'return false;' logic error
- Addresses test coverage gap that allowed the bug to slip through

The original test suite focused on clear containment scenarios but missed
complex overlapping patterns where interval tree finds false positives
that need proper filtering logic.

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

* refactor: separate IntervalTree structure from search algorithm

- Extract ContainmentSearchAlgorithm to separate class
- Make IntervalTree generic and reusable for different search patterns
- Maintain existing API compatibility for filterRedundantDateRanges
- Address GitHub feedback from @keithwillcode

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

* refactor: make IntervalTree truly generic by moving node construction outside

- Extract createIntervalNodes helper function for date-specific node construction
- Remove start/end/maxEnd property handling from IntervalTree constructor
- Make IntervalTree accept pre-constructed nodes instead of raw items
- Move date-specific logic to filterRedundantDateRanges caller
- Addresses GitHub feedback from @keithwillcode about generic tree structure
- Maintains O(n log n) performance and all existing functionality

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

* perf: eliminate redundant sorting in IntervalTree implementation

- Remove duplicate sort in IntervalTree constructor
- Sort interval nodes once in filterRedundantDateRanges after creation
- Addresses @Udit-takkar's performance feedback on PR #22093
- Maintains O(n log n) complexity while reducing constant factors

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

* fix: remove redundant sorting of interval nodes

- Eliminates unnecessary .sort() operation on interval nodes
- Nodes are already sorted from initial sortedRanges sort
- Addresses @keithwillcode's performance feedback

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-03 10:59:33 +01:00
Hariom BalharaandAlex van Andel 9f6c05f389 fix: Avoid prerender duplicate calls for same calLink that could arise with faulty implementation (#21982)
* fix: Avoid prerender duplicate calls

* Update README

* Ensure "noAction" action occurs if nothing changes

* fix bug

* Introduce connect method for reliable re-use

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-03 10:51:13 +01:00
Hariom BalharaandAnik Dhabal Babu f8a1bd7b94 fix: Don't default to ROUND_ROBIN as user bookings don't have it set (#22210)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-03 10:15:55 +05:30
Omar LópezandDevin AI 02967961f3 fix: use triggering user's email/name in draft-release workflow (#22229)
Co-authored-by: zomars@cal.com <zomars@me.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-03 02:49:00 +00:00
Keith WilliamsandDevin AI 053b8b5c0a fix: resolve race condition in ready-for-e2e label checking (#22222)
- Simplify conditional logic in check-label job output
- Remove redundant condition that caused race condition
- Maintain event filtering to only run when ready-for-e2e label is added
- Fixes issue where workflow failed despite script finding the label correctly

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-03 00:21:43 +00:00
Keith Williams d59efd3ba0 chore: Ignore 'dba' for labeling (#22216) 2025-07-02 23:36:02 +00:00
GitHub Actions ce56cd1ab0 chore: release v5.4.12
Post release / release (push) Canceled after 0s
2025-07-02 21:05:34 +00:00
Romit 54b6deee19 fix (#22117) 2025-07-02 23:06:28 +05:30
abhix4 8315641724 fix: the icon in ooo empty view in light mode (#22208) 2025-07-02 22:50:20 +05:30
Anik Dhabal Babu aa3086cfae fix: Can cancel an already cancelled booking through the API (#22128)
* fix: can cancel already cancelled booking

* add test

* fix test

* update

* Update bookings.service.ts

* add commnet

* update

* tweak
2025-07-02 08:40:57 -04:00
devin-ai-integration[bot]andmorgan@cal.com b599ce4919 feat: add user context logging to request interceptor (#22180)
- Import extractUserContext function from @/lib/extract-user-context
- Extract user context from request in intercept method
- Add user context to logger output using spread operator
- Follow same pattern as exception filters for consistency

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-01 21:08:31 +00:00
Benny Joo 52f16604c1 feat: Use a separate project for Client Sentry (#22183) 2025-07-01 19:26:51 +00:00
Benny Joo c98c06062b revert: Process Base64 Avatar Image (#22185)
* Revert "chore: add test for updateProfilePhotoGoogle (#22169)"

This reverts commit 67032ddaf1.

* Revert "fix: Process base64 avatar image (#22165)"

This reverts commit d8d8bc4d00.
2025-07-01 18:38:32 +00:00
Carina WollendorferandCarinaWolli 4963d247dc fix: don't use unpublished org for credit usage (#22154)
* only return publichsed orgs

* fix twilioWebhook tests

* code clean up

* add missing import

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-01 16:04:13 +00:00
9405bc9553 feat: enable stripe invoices for credit purchases (#22096)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-01 16:01:53 +00:00
8a080b91b9 feat: add timezone indicator on insights for unmatched timezone (#21568)
* feat: adds timezone indicator on insights informing user that user settings timezone is used instead of browser timezone

* added i18 helper for translation

* chore: use entities.decodeHTML instead of custom decoder

* feat(insights): align TimezoneBadge with new timeZone prop flow

* chore

* chore: smaller icon

* do not escape value

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-01 12:49:21 +00:00
Lauris Skraucis 4aa05fe523 docs: releasing atoms (#22160) 2025-07-01 17:54:47 +05:30
Lauris Skraucis d74d99dde7 fix: atoms for local development (#22178)
* fix: atoms for local development

* fix: atoms for local development
2025-07-01 17:33:22 +05:30
Parteek malikandSomay Chauhan 0ee137efbb fix: passing props (#21670)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-01 11:21:25 +00:00
GitHub Actions 90f55670dc chore: release v5.4.11
Post release / release (push) Canceled after 0s
2025-07-01 11:06:49 +00:00
Anik Dhabal Babu 9c53f392ca fix: Optional Question ' Leaves Variable Tag in Event Name (#22175) 2025-07-01 10:50:41 +00:00
Morganandcubic-dev-ai[bot] cc19a81875 chore: deprecate platform libraries 0.0.2 (#22149)
* chore: deprecate platform libraries 0.0.2

* fixup! chore: deprecate platform libraries 0.0.2

* Update packages/lib/server/repository/schedule.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* chore: bump platform libs

* Update apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* chore: bump platform libs

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-01 10:23:58 +00:00
Benny Joo 67032ddaf1 chore: add test for updateProfilePhotoGoogle (#22169)
* fix: process base64 avatar image

* better name

* more

* fix import

* add test

* util

* clean up

* address
2025-07-01 11:06:57 +01:00
2fe71c3695 refactor: destination calendars when rescheduling (#21873)
* refactor: destination calendars when rescheduling

* test: add unit test for collective evnet type

* chore: remove log

* chore: bump libraries

* fix: test

* chore: bump libraries

* fix: tests

* reset lock file

* Revert "reset lock file"

This reverts commit 4d48b678778b0daa19624338b302478e4655d200.

* Revert "Revert "reset lock file""

This reverts commit a7c3120c926a0d37a2b55a6ff8d2a070969ec0f4.

* regenerate lock file

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-07-01 12:07:48 +03:00
21422fe027 feat: enhance API v2 exception filters with user context for improved Axiom observability (#22157)
* feat: enhance API v2 exception filters with user context for improved Axiom observability

- Add extractUserContext utility to safely extract user/org/team context from request
- Enhance all 5 exception filters (HTTP, TRPC, Prisma, Zod, Calendar) with user context
- Include userId, userEmail, organizationId, teamId in Axiom logs when available
- Improve observability for debugging and monitoring authenticated requests

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

* chore: update yarn.lock from lint-staged hooks

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

* fix: use request.organization instead of request.organizationId

- Address GitHub comment feedback from ThyMinimalDev
- Extract organization ID from organization object for consistency
- Follows same pattern as user and team extraction

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

* fix: revert to using request.organizationId as requested in GitHub comment

- Change back from request.organization to request.organizationId
- This aligns with how auth strategies actually populate the request object
- Addresses GitHub comment from ThyMinimalDev

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

---------

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: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-01 10:05:21 +03:00
Devanshu Sharma 95575291ff feat: add Finnish locale to Day.js locales (#22174) 2025-07-01 07:01:32 +00:00
KartikandKartik Saini 3e61a0633d prevent info icon from repositioning when text wraps on small screens (#22132)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-01 09:32:41 +05:30
abhix4 85c9a790f2 fix: prevent location input from losing focus after each key stroke (#22167)
* fix: prevent location input from losing focus after each key stroke

* typo
2025-07-01 03:54:35 +00:00
Omar López e17d55457f refactor: cookie domain logic in defaultCookies (#22168) 2025-06-30 20:21:16 +00:00
Benny Joo d8d8bc4d00 fix: Process base64 avatar image (#22165)
* fix: process base64 avatar image

* better name

* more

* fix import
2025-06-30 21:08:46 +01:00
Udit TakkarandAlex van Andel 55cc1aea59 perf: optimize listWithTeamHandler query (#22069)
* perf: optimize listWithTeamHandler query

* chore: update query

* chore: add accepted true

* Update packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts

* test: add integration test for the sql query

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-30 18:35:49 +00:00
GitHub Actions ae383164bc chore: release v5.4.10
Post release / release (push) Canceled after 0s
2025-06-30 13:22:55 +00:00
GitHub Actions 0d8863fe8e chore: release v5.4.10 2025-06-30 13:02:06 +00:00
GitHub Actions 51f6654635 chore: release v5.5.0 2025-06-30 12:39:41 +00:00
Eunjae LeeandDevin AI fa11e10bcb feat: remove 3-attendee limitation from CSV export (#22092)
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-06-30 12:28:54 +00:00
github-actions[bot] a8f5bd6fe9 chore: version packages (#22152)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-30 11:16:15 +00:00
Lauris Skraucis a1c725bb35 refactor: atoms peer deps (#22153) 2025-06-30 10:22:45 +00:00
Anik Dhabal BabuandHariom Balhara e4eeb11a61 fix: fix request reschedule - not allowing reschedule of cancelled booking (#22115)
* fix: fix broken request reschdule feature

* Update getServerSideProps.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-30 09:56:45 +00:00
Udit TakkarandHariom 3b0bb25251 fix: status code or router/embed rate limiting (#21968)
* fix: status code or router embed rate limiting

* fix: type err

* fix: handle api v2

* fix: status code

* chore: remove unused

* chore: update unit test

* chore: unit test

* refactor: make res required

* chore: remove unused

* chore: add a wrapper in getserversideprops

* chore: remlove log

* Add ratelLimiting unit test

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-30 14:58:44 +05:30
Lauris Skraucis d27490e6f2 fix: atoms dist size (#22148)
* fix: reduce atoms dist size

* test changesets entry

* bump version
2025-06-30 09:26:08 +00:00
Anik Dhabal BabuandUdit Takkar b3c191b553 fix: truncate zoom meeting agenda (#22137)
* fix truncate zoom agenda

* type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-06-30 08:45:13 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] 0b977e615d fix: exclude opt out message from body (#22100)
* fix: exclude opt out

* Update packages/features/ee/workflows/api/scheduleSMSReminders.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-30 08:17:36 +00:00
110 changed files with 5724 additions and 1994 deletions
+1
View File
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_SAMPLE_RATE=
+18 -2
View File
@@ -27,8 +27,24 @@ jobs:
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
# Define authorized users and their emails using organization secret
# Expected format: JSON object with username -> email mapping
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
# Extract email for the triggering user
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
# Fail if user is not authorized or not in mapping
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
echo "Only authorized team members can trigger releases."
echo "Contact your administrator to be added to the authorized users list."
exit 1
fi
git config --local user.email "$USER_EMAIL"
git config --local user.name "${{ github.actor }}"
- uses: ./.github/actions/yarn-install
+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, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
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, teams, webhooks, workflows, zapier"
apply-labels-from-issue:
runs-on: ubuntu-latest
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
permissions:
pull-requests: read
outputs:
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
steps:
- name: Check if PR exists with ready-for-e2e label for this SHA
id: check-if-pr-has-label
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
View File
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
-3
View File
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
/** @type {import("next").NextConfig} */
const nextConfig = {
experimental: {
instrumentationHook: true,
},
transpilePackages: [
"@calcom/app-store",
"@calcom/dayjs",
+1 -1
View File
@@ -33,7 +33,7 @@
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^13.5.9",
"next": "^15.3.0",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
+1 -2
View File
@@ -38,8 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
}
if (bookingUid) {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
if (bookingInfo.status === "CANCELLED") {
throw new BadRequestException(
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
);
}
try {
req.body.uid = bookingUid;
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
@@ -2553,6 +2553,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cant't cancel already cancelled booking", () => {
it("should not be able to cancel alraedy cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
});
describe("calendar events", () => {
beforeEach(() => {
jest.restoreAllMocks();
@@ -711,6 +711,12 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
}
if (booking.status === "CANCELLED") {
throw new BadRequestException(
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
);
}
const oAuthClientParams = booking.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
: undefined;
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
@@ -33,13 +34,19 @@ import {
HttpStatus,
Delete,
Query,
Headers,
InternalServerErrorException,
ParseIntPipe,
} from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
export class EventTypesController_2024_04_15 {
constructor(
private readonly eventTypesService: EventTypesService_2024_04_15,
private readonly prismaReadService: PrismaReadService
private readonly prismaReadService: PrismaReadService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@Post("/")
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
async getPublicEventType(
@Param("username") username: string,
@Param("eventSlug") eventSlug: string,
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<GetEventTypePublicOutput> {
try {
let orgSlug = queryParams.org;
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
if (org) {
orgSlug = org.slug;
}
}
const event = await getPublicEvent(
username.toLowerCase(),
eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
orgSlug ?? null,
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
status: SUCCESS_STATUS,
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
imports: [
PrismaModule,
MembershipsModule,
TokensModule,
UsersModule,
SelectedCalendarsModule,
OrganizationsModule,
],
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
controllers: [EventTypesController_2024_04_15],
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
description: "undefined booking fields",
length: 40,
hidden: false,
slug: "undefined-booking-fields",
slug: `undefined-booking-fields-${randomString()}`,
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
disableOnPrefill: false,
hidden: false,
},
{
disableOnPrefill: false,
hidden: true,
isDefault: true,
required: false,
slug: "attendeePhoneNumber",
type: "phone",
},
{
isDefault: true,
type: "radioInput",
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
@Body() bodySchedule: CreateScheduleInput_2024_04_15
): Promise<CreateScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
@GetUser() user: UserWithProfile
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
const scheduleFormatted = schedule
? await this.schedulesService.formatScheduleForAtom(user, schedule)
: null;
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@Get("/")
@Permissions([SCHEDULE_READ])
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
const schedules = await this.schedulesService.getUserSchedules(
user.id,
user.timeZone,
user.defaultScheduleId
);
return {
status: SUCCESS_STATUS,
data: schedulesFormatted,
data: schedules,
};
}
@@ -1,26 +1,22 @@
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { User } from "@prisma/client";
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries-0.0.2";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class SchedulesService_2024_04_15 {
constructor(
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
}
return createdSchedule;
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
return formattedSchedule;
}
async getUserScheduleDefault(userId: number) {
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
if (!user?.defaultScheduleId) return null;
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
return await ScheduleRepository.findDetailedScheduleById({
scheduleId: user.defaultScheduleId,
isManagedEventType: undefined,
userId,
timeZone: user.timeZone,
defaultScheduleId: user.defaultScheduleId,
});
}
async getUserSchedule(userId: number, scheduleId: number) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
const user = await this.usersRepository.findById(userId);
if (!user) {
throw new NotFoundException(`User with ID=${userId} does not exist.`);
}
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
scheduleId: scheduleId,
isManagedEventType: undefined,
userId,
timeZone: user.timeZone,
defaultScheduleId: user.defaultScheduleId,
});
if (!existingSchedule) {
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
return existingSchedule;
}
async getUserSchedules(userId: number) {
return this.schedulesRepository.getSchedulesByUserId(userId);
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
}
async updateUserSchedule(
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
this.checkUserOwnsSchedule(user.id, existingSchedule);
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
if (!bodySchedule.schedule) {
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
// schedule.
bodySchedule.schedule = scheduleFormatted.availability;
bodySchedule.schedule = schedule.availability;
}
return updateScheduleHandler({
input: { scheduleId: Number(scheduleId), ...bodySchedule },
ctx: { user },
return updateSchedule({
input: {
scheduleId: Number(scheduleId),
...bodySchedule,
},
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
return this.schedulesRepository.deleteScheduleById(scheduleId);
}
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
}
async formatSchedulesForAtom(
user: User,
schedules: ScheduleWithAvailabilities[]
): Promise<ScheduleOutput[]> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return Promise.all(
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
);
}
async transformScheduleForAtom(
schedule: ScheduleWithAvailabilities,
userSchedulesCount: number,
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
): Promise<ScheduleOutput> {
const timeZone = schedule.timeZone || user.timeZone;
const defaultSchedule = await this.getUserScheduleDefault(user.id);
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== user.id,
workingHours: transformWorkingHoursForClient(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForClient(schedule),
timeZone,
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
isDefault: defaultSchedule?.id === schedule.id,
isLastSchedule: userSchedulesCount <= 1,
readOnly: schedule.userId !== user.id,
};
}
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
if (userId !== schedule.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(400).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const statusCode = exception.getStatus();
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
let message = "There was an error, please try again later.";
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`ZodError: ${error.message}`, {
error,
body: request.body,
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(HttpStatus.BAD_REQUEST).json({
@@ -0,0 +1,38 @@
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
export interface UserContext {
userId?: string;
userEmail?: string;
organizationId?: string;
teamId?: string;
userOrgId?: string;
}
export function extractUserContext(request: Request): UserContext {
const context: UserContext = {};
const user = (request as any).user as ApiAuthGuardUser | undefined;
if (user) {
context.userId = String(user.id);
context.userEmail = user.email;
if (user.profiles?.[0]?.organizationId) {
context.userOrgId = String(user.profiles?.[0].organizationId);
}
}
const organizationId = (request as any).organizationId as number | undefined;
if (organizationId) {
context.organizationId = String(organizationId);
}
const team = (request as any).team as Team | undefined;
if (team) {
context.teamId = String(team.id);
}
return context;
}
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
import { Request, Response } from "express";
import { tap } from "rxjs/operators";
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const { method, url } = request;
const userContext = extractUserContext(request);
const startTime = Date.now();
return next.handle().pipe(
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
responseTime,
responseBody: jsonBodyString,
timestamp: new Date().toISOString(),
...userContext,
});
})
);
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
return id;
}
async findTeamIdFromClientId(clientId: string) {
async findTeamIdAndSlugFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
},
select: {
id: true,
slug: true,
},
});
}
@@ -9,6 +9,7 @@ import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import { formatTime } from "@calcom/lib/dayjs";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
@@ -118,6 +119,7 @@ function BookingListItem(booking: BookingItemProps) {
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;
@@ -465,7 +467,10 @@ function BookingListItem(booking: BookingItemProps) {
!booking.isRecorded &&
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
const showRecordingActions: ActionType[] = [
const isCalVideoLocation =
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "";
const videoOptionsActions: ActionType[] = [
{
id: "view_recordings",
label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"),
@@ -475,6 +480,14 @@ function BookingListItem(booking: BookingItemProps) {
color: showCheckRecordingButton ? "secondary" : "primary",
disabled: mutation.isPending,
},
{
id: "meeting_session_details",
label: t("get_meeting_session_details"),
onClick: () => {
setMeetingSessionDetailsDialogIsOpen(true);
},
disabled: mutation.isPending,
},
];
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
@@ -516,7 +529,7 @@ function BookingListItem(booking: BookingItemProps) {
paymentCurrency={booking.payment[0].currency}
/>
)}
{(showViewRecordingsButton || showCheckRecordingButton) && (
{isCalVideoLocation && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
@@ -524,6 +537,14 @@ function BookingListItem(booking: BookingItemProps) {
timeFormat={userTimeFormat ?? null}
/>
)}
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
<MeetingSessionDetailsDialog
booking={booking}
isOpenDialog={meetingSessionDetailsDialogIsOpen}
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
timeFormat={userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
@@ -714,8 +735,10 @@ function BookingListItem(booking: BookingItemProps) {
) : null}
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{isBookingInPast && isConfirmed ? <TableActions actions={bookedActions} /> : null}
{(showViewRecordingsButton || showCheckRecordingButton) && (
<TableActions actions={showRecordingActions} />
{isCalVideoLocation && (
<TableActions
actions={[{ id: "video_options", label: t("video_options"), actions: videoOptionsActions }]}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
+1 -1
View File
@@ -4,7 +4,7 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
@@ -117,8 +117,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
// This comes from query param and thus is considered forced
const canRescheduleCancelledBooking =
isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings;
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
const isNonRescheduleableBooking =
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
@@ -131,8 +130,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
if (isNonRescheduleableBooking) {
const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking;
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
const canReschedule =
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
return {
redirect: {
destination: canReschedule ? eventUrl : `/booking/${uid}`,
@@ -19,6 +19,7 @@ import {
MostCancelledBookingsTables,
PopularEventsTable,
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
@@ -62,6 +63,7 @@ function InsightsPageContent() {
<div className="grow" />
<Download />
<DateRangeFilter column={createdAtColumn} />
<TimezoneBadge />
</div>
<div className="my-4 space-y-4">
@@ -652,8 +652,9 @@ const ProfileForm = ({
/>
</div>
{extraField}
<p className="text-subtle mt-1 flex items-center gap-1 text-sm">
<Icon name="info" /> {t("tip_username_plus")}
<p className="text-subtle mt-1 flex gap-1 text-sm">
<Icon name="info" className="mt-0.5 flex-shrink-0" />
<span className="flex-1">{t("tip_username_plus")}</span>
</p>
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.9",
"version": "5.4.13",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -2
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import { IS_SMS_CREDITS_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getPublishedOrgIdFromMemberOrTeamId } from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import prisma from "@calcom/prisma";
@@ -120,7 +120,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
if (!orgId) {
orgId = await getOrgIdFromMemberOrTeamId({
orgId = await getPublishedOrgIdFromMemberOrTeamId({
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
teamId: parsedTeamId,
});
+1 -1
View File
@@ -12,7 +12,7 @@ export default function Router({ form, message, errorMessage }: inferSSRProps<ty
return (
<>
<Head>
<title>{form.name} | Cal.com Forms</title>
<title>{form?.name} | Cal.com Forms</title>
</Head>
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
@@ -48,6 +48,21 @@
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
"disable_recording_for_guests": "Disable recording for guests",
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
"video_options": "Video Options",
"get_meeting_session_details": "Get Meeting Session Details",
"meeting_session_details": "Meeting Session Details",
"meeting_session": "Meeting Session",
"session_id": "Session ID",
"max_participants": "Max Participants",
"unlimited": "Unlimited",
"ongoing": "Ongoing",
"ended": "Ended",
"participants": "Participants",
"anonymous_user": "Anonymous User",
"session_duration": "Session Duration",
"minutes": "minutes",
"no_meeting_sessions_found": "No meeting sessions found",
"not_available": "Not available",
"disable_recording_for_organizer": "Disable recording for organizer",
"disable_transcription_for_guests": "Disable transcription for guests",
"disable_transcription_for_organizer": "Disable transcription for organizer",
@@ -3267,5 +3282,6 @@
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
"restriction_schedule_not_found_error": "Restriction schedule not found",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,11 +1,30 @@
import { wrapGetServerSidePropsWithSentry } from "@sentry/nextjs";
import type { GetServerSidePropsContext } from "next";
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
import { getRoutedUrl, hasEmbedPath } from "@calcom/lib/server/getRoutedUrl";
import { TRPCError } from "@trpc/server";
export const getServerSideProps = wrapGetServerSidePropsWithSentry(async function getServerSideProps(
context: GetServerSidePropsContext
) {
return await getRoutedUrl(context);
try {
return await getRoutedUrl(context);
} catch (error) {
if (error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") {
context.res.statusCode = 429;
const isEmbed = hasEmbedPath(context.req.url || "");
return {
props: {
isEmbed,
form: null,
message: null,
errorMessage: error.message,
},
};
}
throw error;
}
},
"/router");
File diff suppressed because it is too large Load Diff
@@ -2,15 +2,34 @@ import { oauth2_v2 } from "@googleapis/oauth2";
import type { OAuth2Client } from "googleapis-common";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = new oauth2_v2.Oauth2({ auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
const avatarUrl = userDetails.data?.picture;
if (!avatarUrl) {
return;
}
// Handle base64 data
if (
avatarUrl.startsWith("data:image/png;base64,") ||
avatarUrl.startsWith("data:image/jpeg;base64,") ||
avatarUrl.startsWith("data:image/jpg;base64,")
) {
const resizedAvatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(avatarUrl),
userId,
});
await UserRepository.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
return;
}
await UserRepository.updateAvatar({ id: userId, avatarUrl });
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
@@ -25,6 +25,28 @@ import {
ZGetMeetingTokenResponseSchema,
} from "./types";
const meetingParticipantSchema = z.object({
user_id: z.string().nullable(),
participant_id: z.string(),
user_name: z.string().nullable(),
join_time: z.number(),
duration: z.number(),
});
const meetingSessionSchema = z.object({
id: z.string(),
room: z.string(),
start_time: z.number(),
duration: z.number(),
ongoing: z.boolean(),
max_participants: z.number(),
participants: z.array(meetingParticipantSchema),
});
const getMeetingInformationResponseSchema = z.object({
data: z.array(meetingSessionSchema),
});
export interface DailyEventResult {
id: string;
name: string;
@@ -501,6 +523,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
throw new Error(`Something went wrong! Unable to checkIfRoomNameMatchesInRecording. ${err}`);
}
},
getMeetingInformation: async (roomName: string) => {
try {
const res = await fetcher(`/meetings?room=${encodeURIComponent(roomName)}`).then(
getMeetingInformationResponseSchema.parse
);
return res;
} catch (err) {
console.error("err", err);
throw new Error("Something went wrong! Unable to get meeting information");
}
},
};
};
@@ -4,7 +4,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
SCOPE_USERINFO_PROFILE,
WEBAPP_URL,
WEBAPP_URL_FOR_OAUTH,
} from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
@@ -14,6 +19,7 @@ import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { updateProfilePhotoGoogle } from "../../_utils/oauth/updateProfilePhotoGoogle";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
@@ -95,10 +101,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
}
// Only attempt to update the user's profile photo if the user has granted the required scope
// TODO: Use the avatarUrl when setting the profile picture
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
// }
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
}
const selectedCalendarWhereUnique = {
userId: req.session.user.id,
@@ -10,6 +10,7 @@ import { RetryableError } from "@calcom/lib/crmManager/errors";
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
import { prisma } from "@calcom/prisma";
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -1244,6 +1245,16 @@ export default class SalesforceCRMService implements CRM {
return;
}
valueToWrite = await this.getTextValueFromBookingTracking(fieldValue, bookingUid);
} else if (fieldValue === "{assignmentReason}") {
if (!bookingUid) {
log.error(`BookingUid not passed. Cannot get assignment reason without it`);
return;
}
valueToWrite = await this.getAssignmentReason(bookingUid);
if (!valueToWrite) {
log.error(`No assignment reason found for bookingUid ${bookingUid}`);
return;
}
} else {
// Get the value from the booking response
if (!calEventResponses) {
@@ -1336,6 +1347,11 @@ export default class SalesforceCRMService implements CRM {
return tracking[`utm_${utmParam}` as keyof typeof tracking]?.toString() ?? "";
}
private async getAssignmentReason(bookingId: string) {
const assignmentReason = await AssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
return assignmentReason?.reasonString ?? "";
}
private getTextValueFromBookingResponse(fieldValue: string, calEventResponses: CalEventResponses) {
const regexValueToReplace = /\{(.*?)\}/g;
return fieldValue.replace(regexValueToReplace, (match, captured) => {
@@ -155,6 +155,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
};
};
// Zoom agenda field has a 2000 character limit; we set maxLength to 1900 to leave a safety buffer
const truncateAgenda = (description?: string | null) => {
if (!description) return description;
const maxLength = 1900;
const trimmed = description.trimEnd();
if (trimmed.length > maxLength) {
return `${trimmed.substring(0, maxLength).trimEnd()}...`;
}
return trimmed;
};
const userSettings = await getUserSettings();
const recurrence = getRecurrence(event);
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
@@ -166,7 +178,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.organizer.timeZone,
password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined,
agenda: event.description,
agenda: truncateAgenda(event.description),
settings: {
host_video: true,
participant_video: true,
+1
View File
@@ -4,6 +4,7 @@ import "dayjs/locale/cs";
import "dayjs/locale/de";
import "dayjs/locale/es";
import "dayjs/locale/es-mx";
import "dayjs/locale/fi";
import "dayjs/locale/fr";
import "dayjs/locale/he";
import "dayjs/locale/hu";
+52 -13
View File
@@ -166,8 +166,8 @@ For example, if your page is `https.example.com/contact`, you can enable logging
#### Routing Prerendering System
The prerendering system optimizes the initial load:
```typescript
Cal.prerender({
calLink: "organization/event-type",
Cal("prerender", {
calLink,
type: "modal"
});
```
@@ -178,24 +178,63 @@ Key aspects:
- Loads the booking page but doesn't send the slots availability request
- Tries to reuse whenever it makes sense and do a fresh load otherwise
Iframe Reuse and Reload Conditions. There could be three situations:
Modal's Iframe Reuse and Reload Conditions. There could be four situations:
1. Reuse
2. Reuse the iframe but refetch the slots
3. Do a fresh load in iframe
1. Show modal as is. No connect and no requests happen - Corresponding action is "noAction"
2. Connect but don't fetch the slots- Corresponding action is "connect-no-slots-fetch"
3. Connect and fetch the slots - Corresponding action is "connect"
4. Do a fresh load in iframe. Corresponding action is "fullReload"
- **Reuse**:
- Modal opens when
- Modal is not in a failed state
- config, params are same as the last time
- No threshold violations
- **Show modal as is**:
- Modal is not in a failed state
- config, params are same as the last time
- No time threshold violations
- **Reuse the iframe but refetch the slots**:
- **Connect but don't fetch the slots**:
- Only embed `config` changes (handled via "connect" flow)
- Query query params changes (handled via "connect" flow)
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
- **Fresh Reload Conditions**:
- **Connect and fetch the slots**:
- Slots are stale
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
- **Do a fresh load in iframe**:
- Different path being loaded(i.e. /pro vs /free)
- Modal is in a failed state
- Time since last render exceeds EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
#### Prerendering headless router
**Without namespace:**
Prerender when there are high chances of user clicking the CTA.
```js
Cal('prerender', {
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
type: "modal",
// Shows skeleton loader for a Team Event's booking slots page
pageType: "team.event.booking.slots"
});
```
Using the prerendered iframe with a CTA:
```js
<button data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
```
**With namespace:**
Prerender when there are high changes of user clicking the CTA.
```js
Cal.ns.myNamespace('prerender', {
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
type: "modal"
// Shows skeleton loader for a Team Event's booking slots page
pageType: "team.event.booking.slots"
});
```
Using the prerendered iframe with a CTA:
```js
<button data-cal-namespace="myNamespace" data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
```
@@ -4,6 +4,8 @@ export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 60 * 1000; // 1 minute
// Keep it high by default to avoid impact on loading time as it would cause a full reload of the iframe user can configure it via options.iframeForceReloadThresholdMs
// Also full reload means that response would be resubmitted.
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
// Ensures that prerender instruction would never re-prerender within one minute as long as the link is the same.
export const EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS = 1 * 60 * 1000; // 1 minute
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
throw new Error(
+126 -534
View File
@@ -5,22 +5,13 @@ import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest";
import {
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
} from "./constants";
import { submitResponseAndGetRoutingResult } from "./utils";
vi.mock("./tailwindCss", () => ({
default: "mockedTailwindCss",
}));
vi.mock("./utils", async () => {
const actual = (await vi.importActual("./utils")) as any;
return {
...actual,
submitResponseAndGetRoutingResult: vi.fn(),
};
});
type ExpectedModalBoxAttrs = {
theme: string;
layout: string;
@@ -335,6 +326,7 @@ describe("Cal", () => {
__prerender: true,
};
calInstance.api.modal(modalArg);
expect(calInstance.isPrerendering).toBe(true);
const modalBox = expectCalModalBoxToBeInDocument({
state: "prerendering",
@@ -399,11 +391,82 @@ describe("Cal", () => {
});
});
it("Accidental repeat prerender prevention", async () => {
const modalArg = {
...baseModalArgs,
calLink: "router?form=123&email=john@example.com",
};
const { status: status1 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status1).toBe("created");
const embedRenderStartTime = calInstance.embedRenderStartTime;
const { status: status2 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status2).toBe("prerender-prevented");
// embedRenderStartTime should not be updated prerender did not happen
expect(calInstance.embedRenderStartTime).toBe(embedRenderStartTime);
});
it("should allow repeat prerender after threshold time has passed", async () => {
vi.useFakeTimers();
const modalArg = {
...baseModalArgs,
calLink: "router?form=123&email=john@example.com",
};
// First prerender
const { status: status1 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status1).toBe("created");
// Mock time passage beyond threshold
vi.advanceTimersByTime(EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS + 1000);
// Second prerender after threshold - should be ALLOWED
const { status: status2 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status2).toBe("created");
vi.useRealTimers();
});
it("Prerender with non-headless router link and then CTA click with headless router is not allowed", async () => {
const modalArg = {
...baseModalArgs,
calLink: "john-doe/meeting",
};
const { status: status1 } = await calInstance.api.modal({
...modalArg,
__prerender: true,
});
expect(status1).toBe("created");
await expect(
calInstance.api.modal({
...modalArg,
calLink: "router?form=123&email=john@example.com",
})
).rejects.toThrow("`prerender` instruction should have been fired with headless router path");
});
describe("Modal State Transitions", () => {
it(`should handle prerender -> open(with prefill) -> reopen scenario`, () => {
it(`should handle prerender -> open(with prefill) -> reopen(with re-submission) scenario`, () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "router?form=FORM_ID&routingField1=value1&routingField2=value2",
};
const { modalBoxUid, expectedConfig } = (function prerender(): {
@@ -412,6 +475,7 @@ describe("Cal", () => {
} {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
expect(calInstance.isPrerendering).toBe(true);
const expectedConfig = {
...modalArg.config,
embedType: "modal",
@@ -425,11 +489,14 @@ describe("Cal", () => {
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
form: "FORM_ID",
"cal.queueFormResponse": "true",
routingField1: "value1",
routingField2: "value2",
}),
origin: null,
},
@@ -439,138 +506,14 @@ describe("Cal", () => {
return { modalBoxUid, expectedConfig };
})();
const { modalArgWithPrefilledConfig } = (function prefill() {
log("Opening the modal with prefill config");
const modalArgWithPrefilledConfig = {
...modalArg,
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
};
// Second modal call with additional config value for prefilling
calInstance.api.modal(modalArgWithPrefilledConfig);
const expectedConfigAfterPrefilling = {
...expectedConfig,
name: modalArgWithPrefilledConfig.config.name,
email: modalArgWithPrefilledConfig.config.email,
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
// Expect the modal to go to loading state - which starts the loader animation
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
// It remains because internally we remove prerender=true in iframe, as it is connect mode
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// This would update the iframe document URL through embed-iframe.
expect(calInstance.doInIframe).toHaveBeenCalledWith({
method: "connect",
arg: {
config: {
...expectedConfigAfterPrefilling,
},
params: {},
},
});
vi.mocked(calInstance.doInIframe).mockClear();
return { modalArgWithPrefilledConfig };
})();
(function reopen() {
log("Reopening the same modal without any changes");
calInstance.api.modal(modalArgWithPrefilledConfig);
// Connect won't happen again
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
// Expect the modal to just go to "reopened" state
state: "reopened",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
// It remains because we are just reopening the same modal
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
})();
});
it(`should handle prerender -> open(with prefill) -> error -> loading scenario`, () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
};
const { modalBoxUid, modalBox, expectedConfig } = (function prerender() {
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
const { modalBox } = expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
const modalBoxUid = modalBox.getAttribute("uid");
return { modalBoxUid, modalBox, expectedConfig };
})();
const { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling } = (function prefill() {
log("Opening the modal with prefill config");
const modalArgWithPrefilledConfig = {
...modalArg,
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
};
// Second modal call with additional config value for prefilling
calInstance.api.modal({ ...buildModalArg(modalArgWithPrefilledConfig) });
calInstance.api.modal(modalArgWithPrefilledConfig);
const expectedConfigAfterPrefilling = {
...expectedConfig,
@@ -588,24 +531,33 @@ describe("Cal", () => {
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
searchParams: new URLSearchParams({
...expectedConfig,
// It remains because internally we remove prerender=true in iframe - through connect flow
// It remains because internally we remove prerender=true in iframe, as it is connect mode
prerender: "true",
"cal.skipSlotsFetch": "true",
form: "FORM_ID",
"cal.queueFormResponse": "true",
routingField1: "value1",
routingField2: "value2",
}),
origin: null,
},
});
// Right now we allow re-submission of the same form data, for which we need to connect again
expect(calInstance.doInIframe).toHaveBeenCalledWith({
method: "connect",
arg: {
config: {
"cal.embed.noSlotsFetchOnConnect": "true",
...expectedConfigAfterPrefilling,
},
params: {},
params: {
routingField1: "value1",
routingField2: "value2",
form: "FORM_ID",
},
},
});
@@ -613,412 +565,52 @@ describe("Cal", () => {
return { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling };
})();
log("Simulating an error state");
modalBox.setAttribute("state", "failed");
(function reopen() {
log("Reopening the same modal but it is in error state, so full page load would be initiated");
log("Reopening the same modal without any changes");
calInstance.api.modal(modalArgWithPrefilledConfig);
expect(calInstance.isPrerendering).toBe(false);
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
// Expect the modal to just go to "reopened" state
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
uid: modalBoxUid,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfigAfterPrefilling,
}),
origin: null,
},
});
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
})();
});
it("should handle prerender(booking link) -> headless router submission -> reuse booking link ", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
// Redirecting to the booking link that is already prerendered
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}/${modalArg.calLink}`,
});
});
await (async function headlessRouterSubmission() {
log("Submitting the routing form headless");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
// State must immediately change to loading, so that loader is shown
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...config,
// It remains because internally we remove prerender=true in iframe as it is connect mode
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// Now check that we connected with the right config
// Connect won't happen again
expect(calInstance.doInIframe).toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
arg: {
config: {
...configWithPrefilledValues,
"cal.embed.noSlotsFetchOnConnect": "true",
embedType: "modal",
...expectedConfigAfterPrefilling,
},
params: {
routingField1: "value1",
routingField2: "value2",
form: "FORM_ID",
},
params: {},
},
})
);
})();
});
it("should handle prerender(booking link) -> headless router submission -> different booking link redirect -> fresh page load", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
// expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
// expectedModalBoxAttrs: {
// // Expect the modal to just go to "reopened" state
// state: "reopened",
// theme: modalArg.config.theme,
// layout: modalArg.config.layout,
// pageType: null,
// uid: modalBoxUid,
// },
// expectedIframeUrlObject: {
// pathname: `/${modalArg.calLink}`,
// searchParams: new URLSearchParams({
// ...expectedConfig,
// // It remains because we are just reopening the same modal
// prerender: "true",
// "cal.skipSlotsFetch": "true",
// }),
// origin: null,
// },
// });
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
const headlessRouterRedirectUrlWithPathOnly = `/something_else_than_prerendered_link`;
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
// Redirecting to the booking link that is already prerendered
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}${headlessRouterRedirectUrlWithPathOnly}?routingFormResponseId=xyz`,
});
});
await (async function headlessRouterSubmission() {
log("Submitting the modal");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const configWithPrefilledValuesAndHeadlessRouterRedirectParams = {
...configWithPrefilledValues,
routingFormResponseId: "xyz",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "loading",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: headlessRouterRedirectUrlWithPathOnly,
searchParams: new URLSearchParams({
...configWithPrefilledValuesAndHeadlessRouterRedirectParams,
}),
origin: null,
},
});
// Now check that we connected with the right config
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
})();
});
it("should handle prerender(booking link) -> headless router submission -> Message ", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
status: "success",
// Redirecting to the booking link that is already prerendered
message: "Fallback Route Message",
});
});
await (async function headlessRouterSubmission() {
log("Submitting the modal");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "has-message",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// Now check that we connected with the right config
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Message");
})();
});
it("should handle prerender(booking link) -> headless router submission -> Error Message", async () => {
// Prerender the modal
const modalArg = {
...baseModalArgs,
calLink: "sales/demo",
};
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
log("Prerendering the modal");
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
const expectedConfig = {
...modalArg.config,
embedType: "modal",
};
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "prerendering",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
return { expectedConfig };
})();
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
return Promise.resolve({
error: "Fallback Route Error",
});
});
await (async function headlessRouterSubmission() {
log("Submitting the modal");
const config = expectedConfig;
const configWithPrefilledValues = {
...config,
name: "John Doe",
email: "john@example.com",
};
const modalOpenPromise = calInstance.api.modal({
...modalArg,
calLink: "router?form=FORM_ID",
config: configWithPrefilledValues,
});
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
expect(calModalBox.getAttribute("state")).toBe("loading");
await modalOpenPromise;
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
expectedModalBoxAttrs: {
state: "failed",
theme: modalArg.config.theme,
layout: modalArg.config.layout,
pageType: null,
},
expectedIframeUrlObject: {
pathname: `/${modalArg.calLink}`,
searchParams: new URLSearchParams({
...expectedConfig,
prerender: "true",
"cal.skipSlotsFetch": "true",
}),
origin: null,
},
});
// Now check that we connected with the right config
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
expect.objectContaining({
method: "connect",
})
);
expect(calModalBox.getAttribute("data-error-code")).toBe("routerError");
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Error");
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
})();
});
});
+239 -180
View File
@@ -6,21 +6,22 @@ import { addAppCssVars } from "./addAppCssVars";
import {
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
} from "./constants";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import css from "./embed.css";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
import { getMaxHeightForModal } from "./ui-utils";
import {
fromEntriesWithDuplicateKeys,
isRouterPath,
submitResponseAndGetRoutingResult,
generateDataAttributes,
getConfigProp,
isSameBookingLink,
buildConfigWithPrerenderRelatedFields,
} from "./utils";
// Exporting for consumption by @calcom/embed-core user
@@ -51,12 +52,8 @@ type CalConfig = {
uiDebug?: boolean;
};
type ModalPrerenderOptions = {
slotsStaleTimeMs?: number;
iframeForceReloadThresholdMs?: number;
reuseFully?: boolean;
// Default value is true right now but it would change soon
backgroundSlotsFetch?: boolean;
type ModalBoxElement = Element & {
uid?: string;
};
type ModalStateData = {
@@ -221,7 +218,7 @@ export class Cal {
__config: CalConfig;
modalBox?: Element;
modalBox?: ModalBoxElement;
inlineEl?: Element;
@@ -688,89 +685,128 @@ export class Cal {
return new URL(`${urlObject.pathname}${urlObject.search}`, urlObject.origin);
}
async submitThroughHeadlessRouterInModal({
modal,
calLinkUrlObject,
stateData,
}: {
modal: { uid: string; element: Element; calOrigin: string | null };
calLinkUrlObject: URL;
stateData: ModalStateData;
}) {
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
const { embedConfig } = stateData;
// We record the updated params on dataset.calLink which allows us to determine if something actually changed since the last time we loaded
// This is used by getNextActionForModal
recordUpdatedParamsForIframe(params: Record<string, string | string[]>) {
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
const calConfig = this.getCalConfig();
const calOrigin = _calOrigin ?? calConfig.calOrigin;
const headlessRouterPageObject = calLinkUrlObject;
const result = await submitResponseAndGetRoutingResult({
headlessRouterPageUrl: headlessRouterPageObject.toString(),
});
log("Headless router result", result);
if ("redirect" in result) {
const routerRedirectUrl = new URL(result.redirect);
const paramsFromRedirect = fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries());
const newEmbedConfig = (this.embedConfig = {
...embedConfig,
...paramsFromRedirect,
});
const actionToTake = this.getNextActionForModal({
modal: { uid: modalBoxUid },
pathWithQueryToLoad: `${routerRedirectUrl.pathname}${routerRedirectUrl.search}`,
stateData: {
...stateData,
embedConfig: newEmbedConfig,
},
});
if (actionToTake === "fullReload") {
if (lastLoadedPathInIframe) {
console.error("Preloaded iframe couldn't be used", {
preloadedPath: lastLoadedPathInIframe,
newPath: routerRedirectUrl.pathname,
});
}
const pathWithoutStartingSlash = routerRedirectUrl.pathname.replace(/^\//, "");
this.loadInIframe({
calLink: pathWithoutStartingSlash,
calOrigin,
config: newEmbedConfig,
iframe: this.iframe as HTMLIFrameElement,
});
} else {
log("Reusing the same iframe for headless router", {
routerRedirectUrl: routerRedirectUrl.toString(),
lastLoadedPathInIframe,
});
// Connection Initiated
this.doInIframe({
method: "connect",
arg: {
config: newEmbedConfig,
params: fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries()),
},
});
}
} else if ("message" in result) {
log("Setting message in modal", {
message: result.message,
});
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.message);
modalEl.setAttribute("state", "has-message");
} else if ("error" in result) {
log("Setting error in modal", {
error: result.error,
});
// We need to show this message in the modal
modalEl.setAttribute("data-error-code", "routerError");
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.error);
modalEl.setAttribute("state", "failed");
if (!lastLoadedUrlInIframeObject || !this.iframe) {
return;
}
// Create a clone of the last loaded url object in iframe
const urlObject = new URL(lastLoadedUrlInIframeObject.toString());
for (const [key, value] of Object.entries(params)) {
urlObject.searchParams.set(key, value as string);
}
this.iframe.dataset.calLink = `${urlObject.pathname.replace(/^\//, "")}${urlObject.search}`;
}
canPrerenderLink({
calLink,
calOrigin,
previousEmbedRenderStartTime,
}: {
calLink: string;
calOrigin: string;
previousEmbedRenderStartTime: number | null;
}) {
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
// Trying to prerender a link that is already prerendered.
// Prevent unnecessary repeat prerenders
if (lastLoadedUrlInIframeObject?.toString() === new URL(calLink, calOrigin).toString()) {
const isThresholdCrossed = hasCrossedThreshold();
if (isThresholdCrossed) {
log("Threshold crossed, allowing repeat prerender");
return true;
}
log("Threshold not crossed, preventing repeat prerender");
return false;
}
return true;
function hasCrossedThreshold() {
if (!previousEmbedRenderStartTime) {
return false;
}
const timeDiff = Date.now() - previousEmbedRenderStartTime;
if (timeDiff >= EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS) {
return true;
}
return false;
}
}
prepareForPrerender({
calLink,
calOrigin,
previousEmbedRenderStartTime,
prerenderOptions,
isHeadlessRouterPath,
}: {
calLink: string;
calOrigin: string;
previousEmbedRenderStartTime: number | null;
prerenderOptions: ModalPrerenderOptions;
isHeadlessRouterPath: boolean;
}) {
if (!this.canPrerenderLink({ calLink, calOrigin, previousEmbedRenderStartTime })) {
return null;
}
if (this.modalBox) {
log("Destroying previous prerendered modalbox");
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
this.modalBox.remove();
}
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
const backgroundSlotsFetch =
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
? DEFAULT_BACKGROUND_SLOTS_FETCH
: prerenderOptions.backgroundSlotsFetch;
const enrichedPrerenderOptions = { ...prerenderOptions, backgroundSlotsFetch };
// Persist prerender options here so that during CTA click we can use these.
this.api.prerenderOptions = enrichedPrerenderOptions;
this.isPrerendering = true;
return { enrichedPrerenderOptions };
}
setModalRenderStartVariables({
embedConfig,
embedRenderStartTime,
}: {
embedConfig: PrefillAndIframeAttrsConfig;
embedRenderStartTime: number;
}) {
this.embedConfig = embedConfig;
this.embedRenderStartTime = embedRenderStartTime;
}
getPreviousModalRenderStartVariables() {
return {
embedConfig: this.embedConfig,
embedRenderStartTime: this.embedRenderStartTime,
};
}
connect({
config,
params,
}: {
config: PrefillAndIframeAttrsConfig;
params: Record<string, string | string[]>;
}) {
// Update the iframe query params and record the change as well on the iframe for getNextActionForModal to use
this.recordUpdatedParamsForIframe(params);
this.doInIframe({
method: "connect",
arg: {
config,
params,
},
});
}
}
@@ -979,56 +1015,24 @@ class CalApi {
__prerender?: boolean;
prerenderOptions?: ModalPrerenderOptions;
}) {
const isPrerendering = !!__prerender;
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
const containerEl = document.body;
const calConfig = this.cal.getCalConfig();
// Clone so that it doesn't mutate the original config
config = { ...config };
// calOrigin could have been passed as empty string by the user
calOrigin = calOrigin || calConfig.calOrigin;
const calLinkUrlObject = new URL(calLink, calOrigin);
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
const { embedConfig: previousEmbedConfig, embedRenderStartTime: previousEmbedRenderStartTime } =
this.cal.getPreviousModalRenderStartVariables();
if (__prerender && this.cal.modalBox) {
log("Destroying previous prerendered modalbox");
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
this.cal.modalBox.remove();
}
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
// `this.prerenderedModalUid` is set for a modal created through "prerender"
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
// Means whether there is already an attempt to use the prerendered modal
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
const containerEl = document.body;
this.cal.isPrerendering = !!__prerender;
if (__prerender) {
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
const backgroundSlotsFetch =
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
? DEFAULT_BACKGROUND_SLOTS_FETCH
: prerenderOptions.backgroundSlotsFetch;
this.prerenderOptions = {
...prerenderOptions,
backgroundSlotsFetch,
};
// Add prerender query param
config.prerender = "true";
// If we are prerendering a headless router path, we don't want to record the response immediately.
if (isHeadlessRouterPath) {
config["cal.queueFormResponse"] = "true";
}
if (!backgroundSlotsFetch) {
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true";
}
}
let enrichedConfig;
const configWithGuestKeyAndColorScheme = withColorScheme(
Cal.ensureGuestKey({
...config,
@@ -1037,41 +1041,99 @@ class CalApi {
containerEl
);
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
const previousEmbedConfig = this.cal.embedConfig;
let enrichedPrerenderOptions;
if (isPrerendering) {
const preparationResult = this.cal.prepareForPrerender({
calLink,
calOrigin,
previousEmbedRenderStartTime,
prerenderOptions,
isHeadlessRouterPath,
});
if (!preparationResult) {
log(`Prevented unnecessary repeat prerender for ${calLink}`);
return {
status: "prerender-prevented",
};
}
enrichedPrerenderOptions = preparationResult.enrichedPrerenderOptions;
enrichedConfig = buildConfigWithPrerenderRelatedFields({
config: configWithGuestKeyAndColorScheme,
isHeadlessRouterPath,
backgroundSlotsFetch: !!this.prerenderOptions?.backgroundSlotsFetch,
});
} else {
this.cal.isPrerendering = false;
enrichedConfig = configWithGuestKeyAndColorScheme;
}
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
// `this.prerenderedModalUid` is set for a modal created through "prerender"
const reusableModalUid = this.modalUid || this.prerenderedModalUid;
const uid = reusableModalUid || String(Date.now());
// Means whether there is already an attempt to use the prerendered modal
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
const embedRenderStartTime = Date.now();
this.cal.embedRenderStartTime = embedRenderStartTime;
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
const stateData = {
embedConfig: enrichedConfig,
previousEmbedConfig,
embedRenderStartTime,
previousEmbedRenderStartTime,
isConnectionInitiated,
// If prerenderOptions are available, use them. Otherwise use the prerenderOptions from the previous call to modal for prerender
prerenderOptions: enrichedPrerenderOptions ?? this.prerenderOptions ?? null,
};
this.cal.setModalRenderStartVariables({
embedConfig: enrichedConfig,
embedRenderStartTime,
});
return this.__modal({
calLink,
enrichedConfig,
calOrigin,
isPrerendering,
stateData,
uid,
containerEl,
});
}
async __modal({
uid,
calLink,
enrichedConfig,
calOrigin,
isPrerendering,
stateData,
containerEl,
}: {
uid: string;
calLink: string;
enrichedConfig: PrefillAndIframeAttrsConfig;
calOrigin: string;
isPrerendering: boolean;
stateData: ModalStateData;
containerEl: HTMLElement;
}) {
const calLinkUrlObject = new URL(calLink, calOrigin);
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
// isConnectionPossible
if (!!existingModalEl && !!this.cal.iframe) {
log(`Trying to reuse modal ${uid}`);
const stateData = {
embedConfig: configWithGuestKeyAndColorScheme,
previousEmbedConfig,
embedRenderStartTime,
previousEmbedRenderStartTime,
isConnectionInitiated,
prerenderOptions: this.prerenderOptions ?? null,
};
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading");
// submitThroughHeadlessRouterInModal would further decide whether full page reload is needed or a connect would suffice
// actionToTake might be "fullReload" and still connect could work in case of headless router because there might be just query params change(which can be handled by connect) of calLink(i.e. ?form=formid&newParam=newValue)
await this.cal.submitThroughHeadlessRouterInModal({
modal: { uid, element: existingModalEl, calOrigin },
calLinkUrlObject,
stateData,
});
throw new Error(
"`prerender` instruction should have been fired with headless router path as calLink(i.e. router?form={FORM_ID}&PARAMS=VALUES)"
);
} else {
const actionToTake = this.cal.getNextActionForModal({
modal: { uid },
@@ -1080,7 +1142,7 @@ class CalApi {
});
if (actionToTake === "noAction") {
if (__prerender) {
if (isPrerendering) {
// A prerender instruction can never show the modal
return;
}
@@ -1100,22 +1162,20 @@ class CalApi {
calLink,
calOrigin,
iframe: this.cal.iframe,
config: configWithGuestKeyAndColorScheme,
config: enrichedConfig,
});
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
this.cal.doInIframe({
method: "connect",
arg: {
config: {
...configWithGuestKeyAndColorScheme,
...(actionToTake === "connect-no-slots-fetch"
? {
"cal.embed.noSlotsFetchOnConnect": "true",
}
: {}),
},
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
this.cal.connect({
config: {
...enrichedConfig,
...(actionToTake === "connect-no-slots-fetch"
? {
"cal.embed.noSlotsFetchOnConnect": "true",
}
: {}),
},
params: paramsToAdd,
});
}
}
@@ -1127,23 +1187,19 @@ class CalApi {
log(`Creating new modal ${uid}`);
if (__prerender) {
if (isPrerendering) {
this.prerenderedModalUid = uid;
} else {
// Intentionally not setting it to avoid the behaviour of reusing the same modal. It was disabled earlier but now can be enabled but we will enable it later.
// this.modalUid = uid;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
let iframe = null;
if (!iframe) {
iframe = this.cal.createIframe({
calLink,
config: configWithGuestKeyAndColorScheme,
config: enrichedConfig,
calOrigin,
});
}
@@ -1152,9 +1208,9 @@ class CalApi {
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
const pageType = getConfigProp(configWithGuestKeyAndColorScheme, "cal.embed.pageType");
const theme = getConfigProp(configWithGuestKeyAndColorScheme, "theme");
const layout = getConfigProp(configWithGuestKeyAndColorScheme, "layout");
const pageType = getConfigProp(enrichedConfig, "cal.embed.pageType");
const theme = getConfigProp(enrichedConfig, "theme");
const layout = getConfigProp(enrichedConfig, "layout");
template.innerHTML = `<cal-modal-box
${generateDataAttributes({
@@ -1169,11 +1225,14 @@ class CalApi {
// Set state through setAttribute so that onAttributeChangedCallback is triggered
this.cal.modalBox.setAttribute("state", "loading");
if (__prerender) {
if (isPrerendering) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
this.handleClose();
containerEl.appendChild(template.content);
return {
status: "created",
};
}
private handleClose() {
+6
View File
@@ -102,3 +102,9 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
export type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
export type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
export type ModalPrerenderOptions = {
slotsStaleTimeMs?: number;
iframeForceReloadThresholdMs?: number;
backgroundSlotsFetch?: boolean;
};
+29
View File
@@ -196,3 +196,32 @@ export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig)
}
return searchParams;
}
export function buildConfigWithPrerenderRelatedFields({
config,
isHeadlessRouterPath,
backgroundSlotsFetch,
}: {
config: PrefillAndIframeAttrsConfig;
isHeadlessRouterPath: boolean;
backgroundSlotsFetch: boolean;
}) {
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
// If we are prerendering a headless router path, we don't want to record the response immediately.
if (isHeadlessRouterPath) {
config["cal.queueFormResponse"] = "true";
}
if (!backgroundSlotsFetch) {
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true";
}
return {
...config,
prerender: "true",
};
}
@@ -1,12 +1,33 @@
import type { Account, IdentityProvider, Prisma, User, VerificationToken } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { Awaitable } from "next-auth";
import type { PrismaClient } from "@calcom/prisma";
import { identityProviderNameMap } from "./identityProviderNameMap";
type CalComAdapter = {
createUser: (data: Prisma.UserCreateInput) => Awaitable<User>;
getUser: (id: string | number) => Awaitable<User | null>;
getUserByEmail: (email: User["email"]) => Awaitable<User | null>;
getUserByAccount: (provider_providerAccountId: {
providerAccountId: Account["providerAccountId"];
provider: User["identityProvider"];
}) => Awaitable<User | null>;
updateUser: (data: Prisma.UserUncheckedCreateInput) => Awaitable<User>;
deleteUser: (id: User["id"]) => Awaitable<User>;
createVerificationToken: (data: VerificationToken) => Awaitable<Omit<VerificationToken, "id">>;
useVerificationToken: (
identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput
) => Awaitable<Omit<VerificationToken, "id"> | null>;
linkAccount: (data: Prisma.AccountCreateInput) => Awaitable<Account>;
unlinkAccount: (
provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput
) => Awaitable<Account>;
};
/** @return { import("next-auth/adapters").Adapter } */
export default function CalComAdapter(prismaClient: PrismaClient) {
export default function CalComAdapter(prismaClient: PrismaClient): CalComAdapter {
return {
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
getUser: (id: string | number) =>
+2 -8
View File
@@ -408,6 +408,7 @@ const BookerComponent = ({
!(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3">
<DatePicker
classNames={customClassNames?.datePickerCustomClassNames}
event={event}
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
@@ -435,14 +436,7 @@ const BookerComponent = ({
initial="visible"
className="md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]">
<DatePicker
classNames={{
datePickerContainer: customClassNames?.datePickerCustomClassNames?.datePickerContainer,
datePickerTitle: customClassNames?.datePickerCustomClassNames?.datePickerTitle,
datePickerDays: customClassNames?.datePickerCustomClassNames?.datePickerDays,
datePickerDate: customClassNames?.datePickerCustomClassNames?.datePickerDate,
datePickerDatesActive: customClassNames?.datePickerCustomClassNames?.datePickerDatesActive,
datePickerToggle: customClassNames?.datePickerCustomClassNames?.datePickerToggle,
}}
classNames={customClassNames?.datePickerCustomClassNames}
event={event}
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
@@ -2,6 +2,7 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { DatePickerClassNames } from "@calcom/features/bookings/Booker/types";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
@@ -69,14 +70,7 @@ export const DatePicker = ({
};
slots?: Slots;
isLoading?: boolean;
classNames?: {
datePickerContainer?: string;
datePickerTitle?: string;
datePickerDays?: string;
datePickerDate?: string;
datePickerDatesActive?: string;
datePickerToggle?: string;
};
classNames?: DatePickerClassNames;
scrollToTimeSlots?: () => void;
}) => {
const { i18n } = useLocale();
+10 -8
View File
@@ -161,14 +161,7 @@ export type CustomClassNames = {
eventMetaTimezoneSelect?: string;
eventMetaChildren?: string;
};
datePickerCustomClassNames?: {
datePickerContainer?: string;
datePickerTitle?: string;
datePickerDays?: string;
datePickerDate?: string;
datePickerDatesActive?: string;
datePickerToggle?: string;
};
datePickerCustomClassNames?: DatePickerClassNames;
availableTimeSlotsCustomClassNames?: {
availableTimeSlotsContainer?: string;
availableTimeSlotsHeaderContainer?: string;
@@ -182,3 +175,12 @@ export type CustomClassNames = {
backButton?: string;
};
};
export type DatePickerClassNames = {
datePickerContainer?: string;
datePickerTitle?: string;
datePickerDays?: string;
datePickerDate?: string;
datePickerDatesActive?: string;
datePickerToggle?: string;
};
@@ -100,6 +100,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
responses: true,
iCalUID: true,
iCalSequence: true,
status: true,
},
});
}
@@ -89,6 +89,19 @@ async function handler(input: CancelBookingInput) {
arePlatformEmailsEnabled,
} = input;
/**
* Important: We prevent cancelling an already cancelled booking.
* A booking could have been CANCELLED due to a reschedule,
* in which case we simply update the existing calendar event and meeting.
* We want to avoid deleting them by a subsequent cancellation attempt.
*/
if (bookingToDelete.status === BookingStatus.CANCELLED) {
throw new HttpError({
statusCode: 400,
message: "This booking has already been cancelled.",
});
}
if (!bookingToDelete.userId || !bookingToDelete.user) {
throw new HttpError({ statusCode: 400, message: "User not found" });
}
@@ -1502,15 +1502,6 @@ async function handler(
evt.videoCallData = undefined;
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
evt.iCalUID = undefined;
} else {
// In case of rescheduling, we need to keep the previous host destination calendar
evt = CalendarEventBuilder.fromEvent(evt)
.withDestinationCalendar(
originalRescheduledBooking?.destinationCalendar
? [originalRescheduledBooking?.destinationCalendar]
: evt.destinationCalendar
)
.build();
}
const updateManager = await eventManager.reschedule(
@@ -0,0 +1,240 @@
import {
createBookingScenario,
getGoogleCalendarCredential,
TestData,
getOrganizer,
getBooker,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
mockCalendarToHaveNoBusySlots,
getDate,
BookingLocations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect, beforeEach } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
beforeEach(() => {
resetTestEmails();
});
describe("Collective Event Reschedule with Destination Calendar Bug", () => {
test(
`should reschedule a collective event booking and ensure both hosts have the booking in their calendars when a second host is added
1. Create a collective event with 1 host and book it
2. Add a second host to the event type
3. Reschedule the booking
4. Verify both hosts have the booking in their calendars
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: "organizer@google-calendar.com",
},
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 30,
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101, // organizer
},
{
id: 102, // second host (will be added to event type)
},
],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@google-calendar.com",
primaryEmail: "event-type-1@google-calendar.com",
},
},
],
users: [
{
id: 101,
name: "Organizer",
email: "organizer@example.com",
username: "organizer",
timeZone: "Asia/Kolkata",
defaultScheduleId: null,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: "organizer@google-calendar.com",
},
},
{
id: 102,
name: "Second Host",
email: "second-host@example.com",
username: "second-host",
timeZone: "Asia/Kolkata",
defaultScheduleId: null,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "second-host@google-calendar.com",
primaryEmail: "second-host@google-calendar.com",
},
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
users: [
{
id: 101, // organizer
},
],
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID_1",
meetingId: "MOCK_ID_1",
meetingPassword: "MOCK_PASS_1",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
credentialId: null,
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "event-type-1@google-calendar.com",
credentialId: undefined,
},
],
iCalUID,
},
],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID_1", // Video meeting ID is not updated during reschedule
password: "MOCK_PASS_1",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const organizerCalendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
update: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_2",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID_2",
},
});
const rescheduleBookingData = getMockRequestDataForBooking({
data: {
start: `${plus2DateString}T11:00:00.000Z`,
end: `${plus2DateString}T11:30:00.000Z`,
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
rescheduledBy: organizer.email,
},
});
const rescheduledBooking = await handleNewBooking({
bookingData: rescheduleBookingData,
});
await expectBookingToBeInDatabase({
description: "",
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
uid: rescheduledBooking.uid!,
eventTypeId: rescheduleBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID_1",
meetingId: "MOCK_ID_1",
meetingPassword: "MOCK_PASS_1",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
externalCalendarId: "event-type-1@google-calendar.com",
},
],
});
expect(organizerCalendarMock.updateEventCalls.length).toBe(1);
expect(organizerCalendarMock.createEventCalls.length).toBe(0);
// Check availability calls for both hosts
expect(organizerCalendarMock.getAvailabilityCalls.length).toBe(2);
},
timeout
);
});
});
@@ -55,7 +55,10 @@ export class StripeBillingService implements BillingService {
success_url: successUrl,
cancel_url: cancelUrl,
metadata: metadata,
});
invoice_creation: {
enabled: true,
},
} as any);
return {
checkoutUrl: session.url,
@@ -0,0 +1,201 @@
"use client";
import { Suspense } from "react";
import dayjs from "@calcom/dayjs";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { PartialReference } from "@calcom/types/EventManager";
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
import { SkeletonText } from "@calcom/ui/components/skeleton";
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
type Session = RouterOutputs["viewer"]["calVideo"]["getMeetingInformation"]["data"][number];
type Participant = Session["participants"][number];
interface IMeetingSessionDetailsDialog {
booking?: BookingItem;
isOpenDialog: boolean;
setIsOpenDialog: React.Dispatch<React.SetStateAction<boolean>>;
timeFormat: number | null;
}
const MeetingSessionDetailsSkeleton = () => {
return (
<div className="flex flex-col gap-4">
{[...Array(2)].map((_, index) => (
<div key={index} className="border-subtle flex w-full flex-col rounded-md border p-4">
<div className="mb-3">
<SkeletonText className="h-5 w-24" />
<SkeletonText className="mt-1 h-4 w-32" />
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{[...Array(4)].map((_, i) => (
<div key={i}>
<SkeletonText className="h-4 w-16" />
<SkeletonText className="mt-1 h-4 w-24" />
</div>
))}
</div>
<div className="mt-4">
<SkeletonText className="mb-2 h-5 w-20" />
<div className="space-y-2">
{[...Array(2)].map((_, i) => (
<div key={i} className="border-subtle rounded border p-2 text-sm">
<div className="flex justify-between">
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-16" />
</div>
<SkeletonText className="mt-1 h-3 w-32" />
</div>
))}
</div>
</div>
</div>
))}
</div>
);
};
const MeetingSessionDetailsList = ({ roomName }: { roomName: string }) => {
const { t } = useLocale();
const { data: meetingInfo, isLoading } = trpc.viewer.calVideo.getMeetingInformation.useQuery(
{ roomName },
{
suspense: true,
}
);
if (isLoading) {
return <MeetingSessionDetailsSkeleton />;
}
if (!meetingInfo || !meetingInfo.data || meetingInfo.data.length === 0) {
return <p className="font-semibold">{t("no_meeting_sessions_found")}</p>;
}
const formatDuration = (totalSeconds: number) => {
if (!totalSeconds) {
return t("ongoing");
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const parts = [];
if (minutes > 0) {
parts.push(`${minutes} ${t("minutes")}`);
}
if (seconds > 0) {
parts.push(`${seconds} ${t("seconds")}`);
}
return parts.join(" ");
};
return (
<div className="flex flex-col gap-4">
{meetingInfo.data.map((session: Session, index: number) => (
<div key={session.id} className="border-subtle flex w-full flex-col rounded-md border p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold">
{t("meeting_session")} {index + 1}
</h3>
<p className="text-subtle text-sm">
{t("session_id")}: {session.id}
</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">{t("start_time")}:</span>
<p className="text-subtle">
{session.start_time
? dayjs(session.start_time * 1000).format("MMM D, YYYY h:mm A")
: t("not_available")}
</p>
</div>
<div>
<span className="font-medium">{t("duration")}:</span>
<p className="text-subtle">
{session.duration ? formatDuration(session.duration) : t("ongoing")}
</p>
</div>
<div>
<span className="font-medium">{t("max_participants")}:</span>
<p className="text-subtle">{session.max_participants || t("unlimited")}</p>
</div>
<div>
<span className="font-medium">{t("status")}:</span>
<p className="text-subtle">{session.ongoing ? t("ongoing") : t("ended")}</p>
</div>
</div>
{session.participants && session.participants.length > 0 && (
<div className="mt-4">
<h4 className="mb-2 text-sm font-medium">{t("participants")}:</h4>
<div className="space-y-2">
{session.participants.map((participant: Participant) => (
<div key={participant.participant_id} className="border-subtle rounded border p-2 text-sm">
<div className="flex justify-between">
<span className="font-medium">{participant.user_name || t("anonymous_user")}</span>
<span className="text-subtle">
{participant.join_time ? dayjs(participant.join_time * 1000).format("h:mm A") : ""}
</span>
</div>
<p className="text-subtle text-xs">
{t("user_id")}: {participant.user_id || t("not_available")}
</p>
<p className="text-subtle text-xs">
{t("participant_id")}: {participant.participant_id}
</p>
{participant.duration && (
<p className="text-subtle text-xs">
{t("session_duration")}: {formatDuration(participant.duration)}
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
);
};
export const MeetingSessionDetailsDialog = (props: IMeetingSessionDetailsDialog) => {
const { t, i18n } = useLocale();
const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props;
const roomName =
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
undefined;
const subtitle = `${booking?.title} - ${dayjs(booking?.startTime).format("ddd")} ${dayjs(
booking?.startTime
).format("D")}, ${dayjs(booking?.startTime).format("MMM")}`;
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<DialogHeader title={t("meeting_session_details")} subtitle={subtitle} />
{roomName ? (
<Suspense fallback={<MeetingSessionDetailsSkeleton />}>
<MeetingSessionDetailsList roomName={roomName} />
</Suspense>
) : (
<p className="font-semibold">{t("no_meeting_sessions_found")}</p>
)}
<DialogFooter>
<DialogClose className="border" />
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -37,6 +37,9 @@ export async function handler(req: NextRequest) {
scheduledDate: {
lte: dayjs().add(2, "hour").toISOString(),
},
retryCount: {
lt: 3, // Don't continue retrying if it's already failed 3 times
},
},
select: {
...select,
@@ -157,6 +160,8 @@ export async function handler(req: NextRequest) {
}
if (message?.length && message?.length > 0 && sendTo) {
const smsMessageWithoutOptOut = message;
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
}
@@ -167,6 +172,7 @@ export async function handler(req: NextRequest) {
body: message,
scheduledDate: reminder.scheduledDate,
sender: senderID,
bodyWithoutOptOut: smsMessageWithoutOptOut,
bookingUid: reminder.booking.uid,
userId,
teamId,
@@ -14,6 +14,7 @@ export async function sendSmsOrFallbackEmail(props: {
phoneNumber: string;
body: string;
sender: string;
bodyWithoutOptOut?: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
@@ -40,7 +41,7 @@ export async function sendSmsOrFallbackEmail(props: {
await sendOrScheduleWorkflowEmails({
to: [fallbackData.email],
subject: fallbackData.t("notification_about_your_booking"),
html: twilioData.body,
html: twilioData.bodyWithoutOptOut || twilioData.body,
replyTo: fallbackData.replyTo,
});
}
@@ -60,6 +61,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
body: string;
scheduledDate: Date;
sender: string;
bodyWithoutOptOut?: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
@@ -96,7 +98,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
await sendOrScheduleWorkflowEmails({
to: [fallbackData.email],
subject: fallbackData.t("notification_about_your_booking"),
html: twilioData.body,
html: twilioData.bodyWithoutOptOut || twilioData.body,
replyTo: fallbackData.replyTo,
sendAt: twilioData.scheduledDate,
referenceUid: reminder.uuid || undefined,
@@ -176,6 +176,8 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
}
if (smsMessage.length > 0) {
const smsMessageWithoutOptOut = smsMessage;
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
smsMessage = await WorkflowOptOutService.addOptOutMessage(
smsMessage,
@@ -196,6 +198,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
bodyWithoutOptOut: smsMessageWithoutOptOut,
bookingUid: evt.uid,
userId,
teamId,
@@ -34,6 +34,11 @@ vi.mock("@calcom/prisma", () => ({
},
}));
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
default: vi.fn().mockResolvedValue(null),
getPublishedOrgIdFromMemberOrTeamId: vi.fn().mockResolvedValue(null),
}));
describe("Twilio Webhook Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -117,10 +122,6 @@ describe("Twilio Webhook Handler", () => {
mockFindFirst.mockResolvedValue(null);
const webhookHandler = (await import("../../../../../../apps/web/pages/api/twilio/webhook")).default;
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
default: vi.fn().mockResolvedValue(null),
}));
const mockRequest = {
method: "POST",
headers: {
@@ -107,6 +107,66 @@ const getLocationInfo = ({
return { locationAvailable, locationDetails };
};
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
customClassNames?: LocationInputCustomClassNames;
disableLocationProp?: boolean;
}) => {
const { t } = useLocale();
const { eventLocationType, index, customClassNames, disableLocationProp, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className={classNames("my-0", customClassNames?.addressInput)}
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
className={customClassNames?.phoneInput}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const Locations: React.FC<LocationsProps> = ({
isChildrenManagedEventType,
disableLocationProp,
@@ -168,64 +228,6 @@ const Locations: React.FC<LocationsProps> = ({
locationOptions: props.locationOptions,
});
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
customClassNames?: LocationInputCustomClassNames;
}) => {
const { eventLocationType, index, customClassNames, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className={classNames("my-0", customClassNames?.addressInput)}
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
className={customClassNames?.phoneInput}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const defaultInitialLocation = defaultValue || null;
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
@@ -459,6 +461,7 @@ const Locations: React.FC<LocationsProps> = ({
eventLocationType={eventLocationType}
index={index}
customClassNames={customClassNames?.organizerContactInput?.locationInput}
disableLocationProp={disableLocationProp}
/>
</div>
<ErrorMessage
@@ -0,0 +1,55 @@
import { useMemo } from "react";
import { useDataTable } from "@calcom/features/data-table";
import NoSSR from "@calcom/lib/components/NoSSR";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
const TimezoneBadgeContent = () => {
const { t } = useLocale();
const { timeZone: userTimezone } = useDataTable();
const timezoneData = useMemo(() => {
// Use Cal's standard CURRENT_TIMEZONE constant
const browserTimezone = CURRENT_TIMEZONE;
if (!browserTimezone || !userTimezone || browserTimezone === userTimezone) return null;
const tooltipContent = t("timezone_mismatch_tooltip", {
browserTimezone,
userTimezone,
interpolation: { escapeValue: false },
});
return {
browser: browserTimezone,
user: userTimezone,
tooltipContent,
badgeContent: userTimezone,
};
}, [userTimezone, t]);
// Don't render anything if no timezone mismatch
if (!timezoneData) {
return null;
}
return (
<Tooltip content={timezoneData.tooltipContent}>
<Badge variant="gray" size="sm" data-testid="timezone-mismatch-badge">
<Icon name="info" />
</Badge>
</Tooltip>
);
};
export const TimezoneBadge = () => {
return (
<NoSSR fallback={null}>
<TimezoneBadgeContent />
</NoSSR>
);
};
@@ -13,3 +13,4 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { RoutedToPerPeriod } from "./RoutedToPerPeriod";
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
export { RoutingKPICards } from "./RoutingKPICards";
export { TimezoneBadge } from "./TimezoneBadge";
@@ -30,16 +30,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-01T00:00:00.000Z",
endDate: "2025-05-01T23:59:59.999Z",
formattedDate: "May 1",
formattedDateFull: "May 1",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-02T00:00:00.000Z",
endDate: "2025-05-02T23:59:59.999Z",
formattedDate: "2",
formattedDateFull: "May 2",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-03T00:00:00.000Z",
endDate: "2025-05-03T23:59:59.999Z",
formattedDate: "3",
formattedDateFull: "May 3",
});
});
@@ -64,26 +67,31 @@ describe("EventsInsights", () => {
startDate: "2025-05-01T00:00:00.000Z",
endDate: "2025-05-03T23:59:59.999Z",
formattedDate: "May 1 - 3",
formattedDateFull: "May 1 - May 3",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-04T00:00:00.000Z",
endDate: "2025-05-10T23:59:59.999Z",
formattedDate: "May 4 - 10",
formattedDateFull: "May 4 - May 10",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-11T00:00:00.000Z",
endDate: "2025-05-17T23:59:59.999Z",
formattedDate: "May 11 - 17",
formattedDateFull: "May 11 - May 17",
});
expect(ranges[3]).toEqual({
startDate: "2025-05-18T00:00:00.000Z",
endDate: "2025-05-24T23:59:59.999Z",
formattedDate: "May 18 - 24",
formattedDateFull: "May 18 - May 24",
});
expect(ranges[4]).toEqual({
startDate: "2025-05-25T00:00:00.000Z",
endDate: "2025-05-25T23:59:59.999Z",
formattedDate: "May 25 - 25",
formattedDateFull: "May 25 - May 25",
});
});
@@ -108,16 +116,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-15T00:00:00.000Z",
endDate: "2025-05-31T23:59:59.999Z",
formattedDate: "May",
formattedDateFull: "May",
});
expect(ranges[1]).toEqual({
startDate: "2025-06-01T00:00:00.000Z",
endDate: "2025-06-30T23:59:59.999Z",
formattedDate: "Jun",
formattedDateFull: "Jun",
});
expect(ranges[2]).toEqual({
startDate: "2025-07-01T00:00:00.000Z",
endDate: "2025-07-15T23:59:59.999Z",
formattedDate: "Jul",
formattedDateFull: "Jul",
});
});
@@ -142,16 +153,19 @@ describe("EventsInsights", () => {
startDate: "2025-06-15T00:00:00.000Z",
endDate: "2025-12-31T23:59:59.999Z",
formattedDate: "2025",
formattedDateFull: "2025",
});
expect(ranges[1]).toEqual({
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T23:59:59.999Z",
formattedDate: "2026",
formattedDateFull: "2026",
});
expect(ranges[2]).toEqual({
startDate: "2027-01-01T00:00:00.000Z",
endDate: "2027-03-15T23:59:59.999Z",
formattedDate: "2027",
formattedDateFull: "2027",
});
});
@@ -176,6 +190,7 @@ describe("EventsInsights", () => {
startDate: "2025-05-01T00:00:00.000Z",
endDate: "2025-05-01T23:59:59.999Z",
formattedDate: "May 1",
formattedDateFull: "May 1",
});
});
});
@@ -205,6 +220,7 @@ describe("EventsInsights", () => {
startDate: "2025-03-29T23:00:00.000Z", // March 30th 00:00 Paris time
endDate: "2025-03-30T21:59:59.999Z", // March 30th 23:59:59 Paris time
formattedDate: "Mar 30",
formattedDateFull: "Mar 30",
});
expect(new Date(ranges[0].endDate).getTime() - new Date(ranges[0].startDate).getTime()).toBeLessThan(
23 * 60 * 60 * 1000
@@ -213,6 +229,7 @@ describe("EventsInsights", () => {
startDate: "2025-03-30T22:00:00.000Z", // March 31st 00:00 Paris time
endDate: "2025-03-31T21:59:59.999Z", // March 31st 23:59:59 Paris time
formattedDate: "31",
formattedDateFull: "Mar 31",
});
});
@@ -237,16 +254,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-15T22:00:00.000Z",
endDate: "2025-05-17T21:59:59.999Z",
formattedDate: "May 16 - 17",
formattedDateFull: "May 16 - May 17",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-17T22:00:00.000Z",
endDate: "2025-05-24T21:59:59.999Z",
formattedDate: "May 18 - 24",
formattedDateFull: "May 18 - May 24",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-24T22:00:00.000Z",
endDate: "2025-05-29T21:59:59.999Z",
formattedDate: "May 25 - 29",
formattedDateFull: "May 25 - May 29",
});
});
@@ -271,11 +291,13 @@ describe("EventsInsights", () => {
startDate: "2025-05-31T22:00:00.000Z",
endDate: "2025-06-30T21:59:59.999Z",
formattedDate: "Jun",
formattedDateFull: "Jun",
});
expect(ranges[1]).toEqual({
startDate: "2025-06-30T22:00:00.000Z",
endDate: "2025-07-31T21:59:59.999Z",
formattedDate: "Jul",
formattedDateFull: "Jul",
});
});
});
@@ -304,11 +326,13 @@ describe("EventsInsights", () => {
startDate: "2025-05-14T15:00:00.000Z",
endDate: "2025-05-15T14:59:59.999Z",
formattedDate: "May 15",
formattedDateFull: "May 15",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-15T15:00:00.000Z",
endDate: "2025-05-16T14:59:59.999Z",
formattedDate: "16",
formattedDateFull: "May 16",
});
});
@@ -333,16 +357,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-11T15:00:00.000Z",
endDate: "2025-05-17T14:59:59.999Z",
formattedDate: "May 12 - 17",
formattedDateFull: "May 12 - May 17",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-17T15:00:00.000Z",
endDate: "2025-05-24T14:59:59.999Z",
formattedDate: "May 18 - 24",
formattedDateFull: "May 18 - May 24",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-24T15:00:00.000Z",
endDate: "2025-05-25T14:59:59.999Z",
formattedDate: "May 25 - 25",
formattedDateFull: "May 25 - May 25",
});
});
@@ -367,11 +394,13 @@ describe("EventsInsights", () => {
startDate: "2025-04-30T15:00:00.000Z",
endDate: "2025-05-31T14:59:59.999Z",
formattedDate: "May",
formattedDateFull: "May",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-31T15:00:00.000Z",
endDate: "2025-06-30T14:59:59.999Z",
formattedDate: "Jun",
formattedDateFull: "Jun",
});
});
});
@@ -421,16 +450,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-01T00:00:00.000Z",
endDate: "2025-05-04T23:59:59.999Z",
formattedDate: "May 1 - 4",
formattedDateFull: "May 1 - May 4",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-05T00:00:00.000Z",
endDate: "2025-05-11T23:59:59.999Z",
formattedDate: "May 5 - 11",
formattedDateFull: "May 5 - May 11",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-12T00:00:00.000Z",
endDate: "2025-05-14T23:59:59.999Z",
formattedDate: "May 12 - 14",
formattedDateFull: "May 12 - May 14",
});
});
@@ -455,16 +487,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-01T00:00:00.000Z",
endDate: "2025-05-03T23:59:59.999Z",
formattedDate: "May 1 - 3",
formattedDateFull: "May 1 - May 3",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-04T00:00:00.000Z",
endDate: "2025-05-10T23:59:59.999Z",
formattedDate: "May 4 - 10",
formattedDateFull: "May 4 - May 10",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-11T00:00:00.000Z",
endDate: "2025-05-14T23:59:59.999Z",
formattedDate: "May 11 - 14",
formattedDateFull: "May 11 - May 14",
});
});
@@ -489,16 +524,19 @@ describe("EventsInsights", () => {
startDate: "2025-05-01T00:00:00.000Z",
endDate: "2025-05-02T23:59:59.999Z",
formattedDate: "May 1 - 2",
formattedDateFull: "May 1 - May 2",
});
expect(ranges[1]).toEqual({
startDate: "2025-05-03T00:00:00.000Z",
endDate: "2025-05-09T23:59:59.999Z",
formattedDate: "May 3 - 9",
formattedDateFull: "May 3 - May 9",
});
expect(ranges[2]).toEqual({
startDate: "2025-05-10T00:00:00.000Z",
endDate: "2025-05-14T23:59:59.999Z",
formattedDate: "May 10 - 14",
formattedDateFull: "May 10 - May 14",
});
});
});
@@ -846,4 +884,204 @@ describe("EventsInsights", () => {
expect(result).toBe("day");
});
});
describe("getDateRanges - Formatting Comparison", () => {
const timeZone = "UTC";
describe("Daily view formatting differences", () => {
it("should show smart vs full formatting for consecutive days", () => {
const startDate = "2024-01-15T00:00:00.000Z";
const endDate = "2024-01-19T23:59:59.999Z";
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "day",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
expect(ranges).toHaveLength(5);
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan 15", "16", "17", "18", "19"]);
expect(formattedDatesFull).toEqual(["Jan 15", "Jan 16", "Jan 17", "Jan 18", "Jan 19"]);
});
it("should show formatting differences across month boundaries", () => {
const startDate = "2024-01-30T00:00:00.000Z";
const endDate = "2024-02-02T23:59:59.999Z";
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "day",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
expect(ranges).toHaveLength(4);
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan 30", "31", "Feb 1", "2"]);
expect(formattedDatesFull).toEqual(["Jan 30", "Jan 31", "Feb 1", "Feb 2"]);
});
});
describe("Weekly view formatting differences", () => {
it("should show smart vs full formatting for weekly ranges", () => {
const startDate = "2024-01-15T00:00:00.000Z"; // Monday
const endDate = "2024-01-28T23:59:59.999Z"; // Sunday
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "week",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
expect(ranges).toHaveLength(2);
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan 15 - 21", "Jan 22 - 28"]);
expect(formattedDatesFull).toEqual(["Jan 15 - Jan 21", "Jan 22 - Jan 28"]);
});
it("should show formatting differences for cross-month weekly ranges", () => {
const startDate = "2024-01-29T00:00:00.000Z"; // Monday
const endDate = "2024-02-11T23:59:59.999Z"; // Sunday
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "week",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
expect(ranges).toHaveLength(2);
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan 29 - Feb 4", "Feb 5 - 11"]);
expect(formattedDatesFull).toEqual(["Jan 29 - Feb 4", "Feb 5 - Feb 11"]);
});
});
describe("Monthly view formatting differences", () => {
it("should show consistent formatting for monthly ranges", () => {
const startDate = "2024-01-15T00:00:00.000Z";
const endDate = "2024-03-15T23:59:59.999Z";
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "month",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
expect(ranges).toHaveLength(3);
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan", "Feb", "Mar"]);
expect(formattedDatesFull).toEqual(["Jan", "Feb", "Mar"]);
});
});
describe("Real-world usage examples", () => {
it("should demonstrate practical differences for chart display", () => {
// Simulate a common analytics scenario: 7 days of data
const startDate = "2024-01-15T00:00:00.000Z";
const endDate = "2024-01-21T23:59:59.999Z";
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "day",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan 15", "16", "17", "18", "19", "20", "21"]);
expect(formattedDatesFull).toEqual([
"Jan 15",
"Jan 16",
"Jan 17",
"Jan 18",
"Jan 19",
"Jan 20",
"Jan 21",
]);
});
it("should show benefits of full formatting for data export", () => {
// Simulate data that might be exported or used in external systems
const startDate = "2024-01-30T00:00:00.000Z";
const endDate = "2024-02-05T23:59:59.999Z";
const ranges = EventsInsights.getDateRanges({
startDate,
endDate,
timeZone,
timeView: "day",
weekStart: "Monday",
});
if (!ranges) {
throw new Error("Expected ranges to be defined");
}
const formattedDates = ranges.map((r) => r.formattedDate);
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
expect(formattedDates).toEqual(["Jan 30", "31", "Feb 1", "2", "3", "4", "5"]);
expect(formattedDatesFull).toEqual(["Jan 30", "Jan 31", "Feb 1", "Feb 2", "Feb 3", "Feb 4", "Feb 5"]);
});
});
});
});
+93 -16
View File
@@ -65,6 +65,7 @@ export interface DateRange {
startDate: string;
endDate: string;
formattedDate: string;
formattedDateFull: string;
}
export interface GetDateRangesParams {
@@ -330,6 +331,13 @@ class EventsInsights {
wholeStart: startDate,
wholeEnd: endDate,
}),
formattedDateFull: this.formatPeriodFull({
start: currentStartDate,
end: currentEndDate,
timeView,
wholeStart: startDate,
wholeEnd: endDate,
}),
});
break;
}
@@ -344,6 +352,13 @@ class EventsInsights {
wholeStart: startDate,
wholeEnd: endDate,
}),
formattedDateFull: this.formatPeriodFull({
start: currentStartDate,
end: currentEndDate,
timeView,
wholeStart: startDate,
wholeEnd: endDate,
}),
});
currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone);
@@ -401,6 +416,46 @@ class EventsInsights {
}
}
static formatPeriodFull({
start,
end,
timeView,
wholeStart,
wholeEnd,
}: {
start: dayjs.Dayjs;
end: dayjs.Dayjs;
timeView: TimeViewType;
wholeStart: dayjs.Dayjs;
wholeEnd: dayjs.Dayjs;
}): string {
const omitYear = wholeStart.year() === wholeEnd.year();
switch (timeView) {
case "day":
return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY");
case "week":
const startFormat = "MMM D";
const endFormat = "MMM D";
if (start.format("YYYY") !== end.format("YYYY")) {
return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`;
}
if (omitYear) {
return `${start.format(startFormat)} - ${end.format(endFormat)}`;
} else {
return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`;
}
case "month":
return omitYear ? start.format("MMM") : start.format("MMM YYYY");
case "year":
return start.format("YYYY");
default:
return "";
}
}
static getCsvData = async (
props: RawDataInput & {
organizationId: number | null;
@@ -484,16 +539,34 @@ class EventsInsights {
: booking.attendees;
const formattedAttendees = attendeeList
.slice(0, 3)
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null));
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
.filter(Boolean);
return [
booking.uid,
{ attendeeList: formattedAttendees, noShowGuest: attendeeList[0]?.noShow || false },
];
})
);
const maxAttendees = Math.max(
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
0
);
const finalBookingMap = new Map(
Array.from(bookingMap.entries()).map(([uid, data]) => {
const attendeeFields: Record<string, string | null> = {};
for (let i = 1; i <= maxAttendees; i++) {
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
}
return [
uid,
{
noShowGuest: attendeeList[0]?.noShow || false,
attendee1: formattedAttendees[0] || null,
attendee2: formattedAttendees[1] || null,
attendee3: formattedAttendees[2] || null,
noShowGuest: data.noShowGuest,
...attendeeFields,
},
];
})
@@ -502,33 +575,37 @@ class EventsInsights {
const data = csvData.map((bookingTimeStatus) => {
if (!bookingTimeStatus.uid) {
// should not be reached because we filtered above
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuest: false,
attendee1: null,
attendee2: null,
attendee3: null,
...nullAttendeeFields,
};
}
const attendeeData = bookingMap.get(bookingTimeStatus.uid);
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
if (!attendeeData) {
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuest: false,
attendee1: null,
attendee2: null,
attendee3: null,
...nullAttendeeFields,
};
}
return {
...bookingTimeStatus,
noShowGuest: attendeeData.noShowGuest,
attendee1: attendeeData.attendee1,
attendee2: attendeeData.attendee2,
attendee3: attendeeData.attendee3,
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
};
});
@@ -382,9 +382,11 @@ function OutOfOfficeEntriesListContent() {
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 text-inverted relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
<Icon name="clock" size={28} />
<Icon name="clock" size={28} className="text-black" />
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold text-black">
z
</span>
</div>
</div>
</div>
+1 -8
View File
@@ -1,7 +1,5 @@
import type { CookieOption, CookiesOptions } from "next-auth";
import { isENVDev } from "@calcom/lib/env";
/**
* Copy from 'https://github.com/nextauthjs/next-auth/blob/227ff2259f/src/core/lib/cookie.ts' as we can't import it directly
*
@@ -20,12 +18,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
const defaultOptions: CookieOption["options"] = {
domain: isENVDev
? process.env.ORGANIZATIONS_ENABLED
? //FIXME: This is causing login to not work if someone uses anything other .cal.local for testing
".cal.local"
: undefined
: NEXTAUTH_COOKIE_DOMAIN,
domain: NEXTAUTH_COOKIE_DOMAIN || undefined,
// To enable cookies on widgets,
// https://stackoverflow.com/questions/45094712/iframe-not-reading-cookies-in-chrome
// But we need to set it as `lax` in development
+4 -2
View File
@@ -199,8 +199,10 @@ export const getUsernameList = (users: string | string[] | undefined): string[]
// Multiple users can come in case of a team round-robin booking and in that case dynamic link won't be a user.
// So, even though this code handles even if individual user is dynamic link, that isn't a possibility right now.
users = arrayCast(users);
const allUsers = users.map((user) => user.replace(/( |%20|%2b)/gi, "+").split("+")).flat();
const allUsers = users
.map((user) => user.replace(/( |%20|%2b)/gi, "+").split("+"))
.flat()
.filter(Boolean);
return Array.prototype.concat(...allUsers.map((userSlug) => slugify(userSlug)));
};
+2
View File
@@ -104,6 +104,8 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
}
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
} else {
dynamicEventName = dynamicEventName.replace(`{${variable}}`, "");
}
});
@@ -42,4 +42,101 @@ describe("filterRedundantDateRanges", () => {
expect(filterRedundantDateRanges(dateRanges)).toEqual(dateRanges);
});
it("should handle three identical ranges", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(1);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T12:00:00.000Z").format());
});
it("should handle nested containment scenario", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
{ start: dayjs("2025-01-23T11:30:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(1);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format());
});
it("should handle ranges with same start time", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(1);
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format());
});
it("should handle ranges with same end time", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(1);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format());
});
it("should preserve invalid ranges", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T11:00:00.000Z") },
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(2);
});
it("should handle touching ranges", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(2);
});
it("should handle overlapping but non-containing ranges", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(2);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
});
it("should handle complex overlapping pattern that exposed cubic-dev-ai bug", () => {
const dateRanges = [
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
{ start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
];
const result = filterRedundantDateRanges(dateRanges);
expect(result.length).toBe(3);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format());
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
expect(result[2].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
});
});
@@ -1,7 +1,9 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import { IntervalTree, ContainmentSearchAlgorithm, createIntervalNodes } from "@calcom/lib/intervalTree";
/**
* Filters out date ranges that are completely covered by other date ranges.
* Uses an interval tree for O(n log n) worst-case complexity.
* Unlike mergeOverlappingDateRanges, this doesn't merge overlapping ranges,
* it only removes ranges that are completely contained within others.
*/
@@ -9,27 +11,38 @@ export function filterRedundantDateRanges(dateRanges: DateRange[]): DateRange[]
if (dateRanges.length <= 1) return dateRanges;
const sortedRanges = [...dateRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
const intervalNodes = createIntervalNodes(
sortedRanges,
(range) => range.start.valueOf(),
(range) => range.end.valueOf()
);
const intervalTree = new IntervalTree(intervalNodes);
const searchAlgorithm = new ContainmentSearchAlgorithm(intervalTree);
return sortedRanges.filter((range, index) => {
if (range.end.valueOf() < range.start.valueOf()) {
return true;
}
for (let i = 0; i < sortedRanges.length; i++) {
if (i === index) continue; // Skip comparing with itself
const containingIntervals = searchAlgorithm.findContainingIntervals(
range.start.valueOf(),
range.end.valueOf(),
index
);
const otherRange = sortedRanges[i];
if (otherRange.end.valueOf() < otherRange.start.valueOf()) {
continue;
}
for (const containingNode of containingIntervals) {
const otherRange = containingNode.item;
const otherIndex = containingNode.index;
if (
otherRange.start.valueOf() <= range.start.valueOf() &&
otherRange.end.valueOf() >= range.end.valueOf()
otherRange.start.valueOf() === range.start.valueOf() &&
otherRange.end.valueOf() === range.end.valueOf()
) {
return false; // This range is redundant, filter it out
return otherIndex > index; // Keep current range only if other range has higher index
}
// If we reach here, the other range actually contains this range
return false;
}
return true; // Keep this range
+58 -33
View File
@@ -1,45 +1,70 @@
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | null) => {
const conditions: Prisma.TeamWhereInput[] = [];
if (memberId) {
conditions.push({
AND: [
{
members: {
some: {
userId: memberId,
accepted: true,
},
},
},
{
isOrganization: true,
},
],
});
}
if (teamId) {
conditions.push({
AND: [
{
children: {
some: {
id: teamId,
},
},
},
{
isOrganization: true,
},
],
});
}
return {
OR: conditions,
};
};
export default async function getOrgIdFromMemberOrTeamId(args: {
memberId?: number | null;
teamId?: number | null;
}) {
const userId = args.memberId ?? 0;
const teamId = args.teamId ?? 0;
const orgId = await prisma.team.findFirst({
where: getOrgMemberOrTeamWhere(args.memberId, args.teamId),
select: {
id: true,
},
});
return orgId?.id;
}
export async function getPublishedOrgIdFromMemberOrTeamId(args: {
memberId?: number | null;
teamId?: number | null;
}) {
const orgId = await prisma.team.findFirst({
where: {
OR: [
{
AND: [
{
members: {
some: {
userId,
accepted: true,
},
},
},
{
isOrganization: true,
},
],
},
{
AND: [
{
children: {
some: {
id: teamId,
},
},
},
{
isOrganization: true,
},
],
},
],
...getOrgMemberOrTeamWhere(args.memberId, args.teamId),
slug: { not: null },
},
select: {
id: true,
+94
View File
@@ -0,0 +1,94 @@
export interface IntervalNode<T> {
item: T;
index: number;
start: number;
end: number;
maxEnd: number;
left?: IntervalNode<T>;
right?: IntervalNode<T>;
}
export function createIntervalNodes<T>(
items: T[],
getStart: (item: T) => number,
getEnd: (item: T) => number
): IntervalNode<T>[] {
return items.map((item, index) => ({
item,
index,
start: getStart(item),
end: getEnd(item),
maxEnd: getEnd(item),
}));
}
export class IntervalTree<T> {
private root?: IntervalNode<T>;
constructor(nodes: IntervalNode<T>[]) {
this.root = this.buildTree([...nodes]);
}
private buildTree(nodes: IntervalNode<T>[]): IntervalNode<T> | undefined {
if (nodes.length === 0) return undefined;
const mid = Math.floor(nodes.length / 2);
const node = nodes[mid];
const leftNodes = nodes.slice(0, mid);
const rightNodes = nodes.slice(mid + 1);
node.left = this.buildTree(leftNodes);
node.right = this.buildTree(rightNodes);
node.maxEnd = Math.max(node.end, node.left?.maxEnd ?? 0, node.right?.maxEnd ?? 0);
return node;
}
getRoot(): IntervalNode<T> | undefined {
return this.root;
}
}
export class ContainmentSearchAlgorithm<T> {
private tree: IntervalTree<T>;
constructor(tree: IntervalTree<T>) {
this.tree = tree;
}
findContainingIntervals(targetStart: number, targetEnd: number, targetIndex: number): IntervalNode<T>[] {
const result: IntervalNode<T>[] = [];
this.searchContaining(this.tree.getRoot(), targetStart, targetEnd, targetIndex, result);
return result;
}
private searchContaining(
node: IntervalNode<T> | undefined,
targetStart: number,
targetEnd: number,
targetIndex: number,
result: IntervalNode<T>[]
): void {
if (!node) return;
if (node.end < node.start) {
this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result);
this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result);
return;
}
if (node.start <= targetStart && node.end >= targetEnd && node.index !== targetIndex) {
result.push(node);
}
if (node.left && node.left.maxEnd >= targetStart) {
this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result);
}
if (node.right && node.start <= targetEnd) {
this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result);
}
}
}
+17
View File
@@ -1,5 +1,6 @@
import "@calcom/lib/__mocks__/logger";
import { createHash } from "crypto";
import type { GetServerSidePropsContext } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -12,12 +13,14 @@ import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substit
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { getRoutedUrl } from "./getRoutedUrl";
// Mock dependencies
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
vi.mock("@calcom/lib/server/repository/routingForm");
vi.mock("@calcom/lib/server/repository/user");
@@ -112,6 +115,7 @@ describe("getRoutedUrl", () => {
it("should return notFound if form is not found", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
const context = mockContext({});
const result = await getRoutedUrl(context);
expect(result).toEqual({ notFound: true });
expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id");
@@ -259,6 +263,19 @@ describe("getRoutedUrl", () => {
);
});
it("should throw an error if rate limit is exceeded", async () => {
vi.mocked(checkRateLimitAndThrowError).mockRejectedValue(new Error("Rate limit exceeded"));
const context = mockContext({ email: "test@cal.com" });
const expectedHash = createHash("sha256")
.update(JSON.stringify({ email: "test@cal.com" }))
.digest("hex");
await expect(getRoutedUrl(context)).rejects.toThrow("Rate limit exceeded");
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
identifier: `form:form-id:hash:${expectedHash}`,
});
});
describe("Dry Run", () => {
it("should call handleResponse with isPreview:true", async () => {
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
+1 -1
View File
@@ -44,7 +44,7 @@ const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown
return hash;
};
function hasEmbedPath(pathWithQuery: string) {
export function hasEmbedPath(pathWithQuery: string) {
const onlyPath = pathWithQuery.split("?")[0];
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
}
@@ -0,0 +1,16 @@
import prisma from "@calcom/prisma";
export class AssignmentReasonRepository {
static async findLatestReasonFromBookingUid(bookingUid: string) {
return await prisma.assignmentReason.findFirst({
where: {
booking: {
uid: bookingUid,
},
},
orderBy: {
createdAt: "desc",
},
});
}
}
@@ -138,6 +138,70 @@ export class ScheduleRepository {
isDefault: !scheduleId || defaultScheduleId === schedule.id,
isLastSchedule: schedulesCount <= 1,
readOnly: schedule.userId !== userId && !isManagedEventType,
userId: schedule.userId,
};
}
static async findManyDetailedScheduleByUserId({
isManagedEventType,
userId,
defaultScheduleId,
timeZone: userTimeZone,
}: {
timeZone: string;
userId: number;
defaultScheduleId: number | null;
isManagedEventType?: boolean;
}) {
const schedules = await prisma.schedule.findMany({
where: {
userId: userId,
},
select: {
id: true,
userId: true,
name: true,
availability: true,
timeZone: true,
},
});
if (!schedules?.length) {
throw new Error("Schedules not found");
}
const isCurrentUserPartOfTeam = await hasReadPermissionsForUserId({
memberId: schedules[0].userId,
userId,
});
const schedulesFormatted = schedules.map((schedule) => {
const isCurrentUserOwner = schedule?.userId === userId;
if (!isCurrentUserPartOfTeam && !isCurrentUserOwner) {
throw new Error("UNAUTHORIZED");
}
const timeZone = schedule.timeZone || userTimeZone;
// disabling utc casting while fetching WorkingHours
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== userId,
workingHours: transformWorkingHoursForAtom(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForAtom(schedule),
timeZone,
isDefault: schedule.id === defaultScheduleId,
dateOverrides: transformDateOverridesForAtom(schedule, timeZone),
readOnly: schedule.userId !== userId && !isManagedEventType,
isLastSchedule: schedules.length <= 1,
userId: schedule.userId,
};
});
return schedulesFormatted;
}
}
+2 -22
View File
@@ -1,28 +1,8 @@
## 1.0.108
## 1.0.112
## 1.0.114
### Patch Changes
- [#22134](https://github.com/calcom/cal.com/pull/22134) [`3437c43`](https://github.com/calcom/cal.com/commit/3437c4331da892bd77afaea84be2db196543a207) Thanks [@supalarry](https://github.com/supalarry)! - ignore this test log
## 1.0.110
### Patch Changes
- [#22107](https://github.com/calcom/cal.com/pull/22107) [`88a2bd3`](https://github.com/calcom/cal.com/commit/88a2bd3317a2cfaf6b367524e2736445544dbd63) Thanks [@supalarry](https://github.com/supalarry)! - test log can ignore
### Patch Changes
- [#22103](https://github.com/calcom/cal.com/pull/22103) [`61274bc`](https://github.com/calcom/cal.com/commit/61274bc7efc67b162d46b59cd75bd376ad515c51) Thanks [@supalarry](https://github.com/supalarry)! - testing changesets - can ignore this
## 1.0.107
### Patch Changes
- [#22095](https://github.com/calcom/cal.com/pull/22095) [`3c43ce4`](https://github.com/calcom/cal.com/commit/3c43ce4165bce1da29b203d5f2eb62090c5fddd2) Thanks [@supalarry](https://github.com/supalarry)! - test by updating changelog
- [#21864](https://github.com/calcom/cal.com/pull/21864) [`540bf86`](https://github.com/calcom/cal.com/commit/540bf868b5b60a98d9d3aeb565e2089f15c3dfd3) Thanks [@supalarry](https://github.com/supalarry)! - fix saving event type settings
- [#22148](https://github.com/calcom/cal.com/pull/22148) [`d27490e`](https://github.com/calcom/cal.com/commit/d27490e6f2438d353dfcf3e793c8886e723307f3) Thanks [@supalarry](https://github.com/supalarry)! - test release
## 1.0.63
+21
View File
@@ -1,5 +1,7 @@
Customizable UI components to integrate scheduling into your services.
## Users guide
### Support
Currently supports React 18, React 19, Next 14 and Next 15.
@@ -16,3 +18,22 @@ want to use version without any suffix e.g. `1.0.103`.
### Documentation
Documentation on how to get started with platform solution is [here](https://cal.com/docs/platform/quickstart) and list of atoms can be viewed
[here](https://cal.com/docs/platform/atoms/cal-provider)
## Contributors guide
### Versioning
We use a tool called changesets that helps documenting changes related to your development branch and then manages
atoms versioning and publishing to [npm](https://www.npmjs.com/package/@calcom/atoms). We need to add a log documenting changes and then letting changesets to gather the changes, update Changelog and update version in `package.json`.
1. Let's say you are on a development branch and just finished adding a new feature to atoms. While on the development branch, you have to add a log documenting this feature so that it later ends up in the atoms [CHANGELOG.md](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/CHANGELOG.md).
- Run `yarn changesets-add` from monorepo root and then select `@calcom/atoms` using space bar and press enter to go to the next step.
- Then, you have to select whether this is a major, minor or patch update following semantic versioning. Since it is a feature skip major by pressing enter and then select minor by pressing space bar and press enter to go to the next step.
- Then, you have to write a description of the change and press enter. This will generate a log file in the `.changeset` directory e.g. `.changeset/hungry-donuts-cross.md`.
- Commit this log file to your development branch and push it.
Notably, you do not have to change `"version"` in the atoms `package.json` file because changesets will do it in the next step.
2. After the development branch is merged changesets will open a pull request titled `chore: version packages` containing next release changes. This pull request will contain the new log file, it being added to the atoms `CHANGELOG.md` file and changesets will update the atoms `package.json` file based whether or not is is major, minor or patch update. When we want to release atoms we simply have to merge this pull request and changesets will publish the new atoms version to npm. Notably,
changesets will publish atoms to npm only if the `"version"` in the atoms `package.json` of changeset's PR is higher than in the npm.
The following 2 articles teach how to write good change summaries for each PR when it deserves to end up in CHANGELOG.md
- https://keepachangelog.com/en/1.0.0/
- https://docs.gitlab.com/development/changelog/
+5 -6
View File
@@ -4,12 +4,14 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.0.112",
"version": "1.0.114",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
"publish-npm": "yarn build && npm publish --access public",
"test": "jest"
"test": "jest",
"dev-on": "node scripts/dev-on.js",
"dev-off": "git checkout -- package.json vite.config.ts"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -37,11 +39,9 @@
"dist",
"globals.min.css"
],
"main": "./dist/cal-atoms.umd.cjs",
"module": "./dist/cal-atoms.js",
"exports": {
".": {
"require": "./dist/cal-atoms.umd.cjs",
"import": "./dist/cal-atoms.js",
"types": "./dist/index.d.ts"
},
@@ -83,7 +83,6 @@
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-awesome-query-builder": "^5.1.2",
"react-dom": "^18.0.0 || ^19.0.0"
"typescript": "^5.0.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJsonPath = path.join(__dirname, "../package.json");
const viteConfigPath = path.join(__dirname, "../vite.config.ts");
// Modify package.json
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
packageJson.main = "./dist/cal-atoms.umd.cjs";
packageJson.exports["."].require = "./dist/cal-atoms.umd.cjs";
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Modify vite.config.ts
let viteConfig = fs.readFileSync(viteConfigPath, "utf-8");
viteConfig = viteConfig.replace(/formats: \["es"\],/g, "");
viteConfig = viteConfig.replace(/external: \[([^\]]+)\]/, 'external: [$1, "react-awesome-query-builder"]');
viteConfig = viteConfig.replace(/format: "esm",\s*/g, "");
fs.writeFileSync(viteConfigPath, viteConfig);
+21 -5
View File
@@ -31,16 +31,32 @@
"../../types/@wojtekmaj__react-daterange-picker.d.ts",
"../../types/business-days-plugin.d.ts",
"../../types/window.d.ts",
"../../lib",
"../../features",
"../types",
"../constants",
"../utils",
"../libraries",
"../../prisma",
"../../dayjs",
"../../trpc",
"../../app-store"
"../../prisma/zod-utils.ts",
"../../prisma/zod",
"../../trpc/react",
"../../trpc/server/routers/viewer/slots",
"../../trpc/server/types",
"../../features/eventtypes",
"../../features/schedules",
"../../features/bookings/Booker",
"../../features/bookings/Booker",
"../../features/instant-meeting",
"../../features/bookings/handleMarkNoShow.ts",
"../../features/bookings/types.ts",
"../../features/bookings/lib",
"../../features/ee/workflows",
"../../features/ee/payments",
"../../features/ee/round-robin",
"../../features/ee/billing",
"../../features/shell",
"../../features/components",
"../../features/form-builder",
"../../features/data-table"
],
"exclude": [
"dist",
+5 -6
View File
@@ -3,7 +3,6 @@ import path from "path";
import { resolve } from "path";
import { defineConfig, loadEnv } from "vite";
import dts from "vite-plugin-dts";
import Inspect from "vite-plugin-inspect";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
@@ -20,7 +19,7 @@ export default defineConfig(({ mode }) => {
"@calcom/platform-utils",
],
},
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
plugins: [react(), dts({ insertTypesEntry: true })],
define: {
"process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
},
@@ -28,17 +27,16 @@ export default defineConfig(({ mode }) => {
noExternal: ["turndown"], // Example if you want to disable SSR for your library
},
build: {
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
},
lib: {
entry: [resolve(__dirname, "index.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
formats: ["es"],
},
rollupOptions: {
external: ["react", "fs", "path", "os", "react-dom", "react-awesome-query-builder"],
external: ["react", "fs", "path", "os", "react/jsx-runtime", "react-dom", "react-dom/client"],
output: {
format: "esm",
globals: {
react: "React",
"react-dom": "ReactDOM",
@@ -58,6 +56,7 @@ export default defineConfig(({ mode }) => {
"@radix-ui/react-dialog": path.resolve(__dirname, "./src/components/ui/dialog.tsx"),
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
Binary file not shown.
@@ -489,7 +489,7 @@ export async function getBookings({
"EventType.disableCancelling",
"EventType.disableRescheduling",
eb
.cast<SchedulingType>(
.cast<SchedulingType | null>(
eb
.case()
.when("EventType.schedulingType", "=", "roundRobin")
@@ -498,7 +498,7 @@ export async function getBookings({
.then(SchedulingType["COLLECTIVE"])
.when("EventType.schedulingType", "=", "managed")
.then(SchedulingType["MANAGED"])
.else(SchedulingType["ROUND_ROBIN"]) // Ensure ELSE provides a value within SchedulingTypeLiteral for cast safety
.else(null)
.end(),
"varchar" // Or 'text' - use the actual SQL data type
)
@@ -2,10 +2,12 @@ import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema";
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
import { ZGetMeetingInformationInputSchema } from "./getMeetingInformation.schema";
type CalVideoRouterHandlerCache = {
getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler;
getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler;
getMeetingInformation?: typeof import("./getMeetingInformation.handler").getMeetingInformationHandler;
};
export const calVideoRouter = router({
@@ -26,4 +28,12 @@ export const calVideoRouter = router({
return getDownloadLinkOfCalVideoRecordingsHandler({ ctx, input });
}),
getMeetingInformation: authedProcedure
.input(ZGetMeetingInformationInputSchema)
.query(async ({ ctx, input }) => {
const { getMeetingInformationHandler } = await import("./getMeetingInformation.handler");
return getMeetingInformationHandler({ ctx, input });
}),
});
@@ -0,0 +1,33 @@
import DailyVideoApiAdapter from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TGetMeetingInformationInputSchema } from "./getMeetingInformation.schema";
type GetMeetingInformationOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetMeetingInformationInputSchema;
};
export const getMeetingInformationHandler = async ({ ctx: _ctx, input }: GetMeetingInformationOptions) => {
const { roomName } = input;
try {
const videoApiAdapter = DailyVideoApiAdapter();
if (!videoApiAdapter || !videoApiAdapter.getMeetingInformation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Meeting information feature not available",
});
}
const res = await videoApiAdapter.getMeetingInformation(roomName);
return res;
} catch (err) {
throw new TRPCError({
code: "BAD_REQUEST",
});
}
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetMeetingInformationInputSchema = z.object({
roomName: z.string(),
});
export type TGetMeetingInformationInputSchema = z.infer<typeof ZGetMeetingInformationInputSchema>;

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