Compare commits

..
163 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
github-actions[bot]andLauris Skraucis 979e1fd28b chore: version packages (#22143)
* chore: version packages

* Update package.json

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-06-30 07:51:11 +00:00
Lauris Skraucis 3437c4331d fix: changesets atoms size (#22134)
* fix: changesets atoms npm package size

* test changesets entry
2025-06-30 07:22:09 +00:00
github-actions[bot] 616d62094c chore: version packages (#22116)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-29 13:25:48 +02:00
ce7ff1c43c fix(signup): add missing key prop to feature list (#21437) (#21506)
* fix(signup): add missing key prop to feature list (#21437)

* refactor

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-28 09:27:25 +00:00
Romit e27e41bc16 fix (#22112) 2025-06-28 14:30:54 +05:30
Hariom Balhara 93e183e6c3 fix: Zoom - More logging and fix for unexpected errors seen in logs (#22118)
* fix: Use WEBAPP_URL_FOR_OAUTH to be able to use http://localhost:3000
for local testing

* In case of JSON.parse crash that could happen if zoom reponsed with xml

* Fix cases where auto_recording and default_password_for_scheduled_meetings could be nullish
2025-06-28 08:11:25 +00:00
Lauris Skraucis 88a2bd3317 fix: changesets release memory (#22107)
* chore: increase instance size for changesets

* test release

* Update CHANGELOG.md
2025-06-28 04:37:47 +00:00
Keith Williams 45caaa55b0 chore: Use DBA team for prisma CODEOWNER (#22109) 2025-06-27 23:01:32 +00:00
GitHub Actions 8c9b7a701d chore: release v5.4.9
Post release / release (push) Canceled after 0s
2025-06-27 22:17:36 +00:00
c47b992125 perf: optimize slot conflict checking from O(n²) to O(n log n) (#22101)
* perf: optimize slot conflict checking from O(n²) to O(n log n)

- Replace nested mapping with pre-sorted busy slots for faster lookups
- Use early termination when busy slots are sorted by start time
- Maintain exact same interface and behavior as original checkForConflicts
- All existing tests pass, preserving edge case handling
- Reduces complexity from O(available slots × busy slots) to O(n log n)

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

* refactor: move O(n log n) optimization to checkForConflicts function

- Replace inline optimization with optimized checkForConflicts function
- Maintain same performance while improving code architecture
- Avoid duplication of conflict checking logic
- Keep all existing interfaces and behavior unchanged

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

* test: add comprehensive unit tests for checkForConflicts function

- Add 20 additional test cases covering edge cases and boundary conditions
- Test multiple busy periods scenarios and complex overlaps
- Comprehensive currentSeats handling scenarios
- Timezone and cross-day boundary testing
- Performance testing with large datasets
- Fix invalid date generation in test data that was causing NaN conflicts
- Achieve near 100% coverage of all conflict scenarios documented in function comments
- Verify behavioral equivalence between original O(n²) and optimized O(n log n) implementations

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

* Simple, safe performance tweak

* Explicitly define the input of busy to be either Date or Dayjs, not string

* EventBusyDate must ALWAYS be a type that supports valueOf

* Revert "EventBusyDate must ALWAYS be a type that supports valueOf"

This reverts commit 35b572266219d64c22db3c3e24b0cb6ca7a901a6.

* Revert "Explicitly define the input of busy to be either Date or Dayjs, not string"

This reverts commit 902f29708e4b7990a4e5e2865cab2f18ab3e44f0.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-27 22:10:17 +00:00
Alex van Andel 493c4f89dc Remove redundant logging (#22108) 2025-06-27 21:01:01 +00:00
Lauris Skraucis 27234dc809 fix: changesets memory issue (#22105) 2025-06-27 16:06:13 +00:00
github-actions[bot] 5690b8e1d5 chore: version packages (#22104)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-27 15:19:56 +00:00
Lauris Skraucis 61274bc7ef fix: changesets release (#22103) 2025-06-27 14:37:23 +00:00
github-actions[bot] e66be23996 chore: version packages (#22098)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-27 12:35:20 +00:00
Lauris Skraucis 3c43ce4165 chore: test changesets (#22095)
* chore: test changesets

* bump atoms package.json

* Revert "bump atoms package.json"

This reverts commit 6105f8cf2486085e5da317a6969044693f4a3aa0.

* chore: set packages to private

* ignore private packages

* ignore private
2025-06-27 13:49:28 +02:00
Somay Chauhan 5060824795 fix: EventAvailabilityTab atom breaking (#22059) 2025-06-27 09:40:38 +00:00
c4941f25a2 fix: no available users found (recurring events) (#22033)
* only check first slot

* skip test for second slot availability check

* fixing tests in handleNewRecurringBooking.test.ts

* skip test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-27 09:31:56 +00:00
e7c219905f fix: add shape docs to avatar (#20552)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-27 09:09:13 +00:00
Udit TakkarandHariom Balhara 499cbd08ca feat: Phone based bookings for everyone (#21320) (#22024)
* Revert "Revert "feat: Phone based bookings for everyone (#21320)" (#22018)"

This reverts commit 80e2118e68.

* chore: don't use repository

* chore: create getTeamWithOrganizationSettings function

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-27 10:09:04 +01:00
Eunjae Lee 0a3b3a69e3 fix: add InsightsRoutingService and refactor InsightsBookingRepository (#22057)
* chore: convert InsightsBookingRepository to service

* remove repository

* rename

* clean up

* add InsightsRoutingService

* add tests
2025-06-27 11:08:46 +02:00
Lauris Skraucis e8607845e2 fix: changesets atoms release (#22090) 2025-06-27 09:07:02 +00:00
02ef4db0eb feat: Add rerouting to AssignmentReasonRecorder (#19252)
* Add `rerouting` to `AssignmentReasonRecorder`

* Pass session email as `rescheduledBy`

* Write reassign reason

* Edit message

* Use `routingFormRoute` when rerouting

* Pass isRerouting & reroutedByEmail to `routingFormRoute` method

* Type fix

* Fix test

* Fix test

* Fix test

* Type fix

* Refactor to use UserRepository

* Show rerouted by email

* Replace isReroutingCase

* Fix rescheduledBy for new tab reschduling

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-27 10:06:39 +01:00
sean-brydon f8d48a341d show icon button when one item in array (#22054) 2025-06-27 07:52:18 +00:00
GitHub Actions 251393101a chore: release v5.4.8
Post release / release (push) Canceled after 0s
2025-06-26 23:56:56 +00:00
Joe Au-Yeung b50430114f Disable setting profile picture (#22074) 2025-06-26 23:48:38 +00:00
Joe Au-Yeung b0999abc68 fix: Generate new idempotency key when round robin reassigning (#22065)
* Create idempotencyKeyService

* Use `idempotencyKeyService` in middleware

* Use `idempotencyKeyService` in RR reassignments
2025-06-26 17:43:31 +00:00
Lauris Skraucis 2bbd92db13 chore: bump platform libraries (#22063) 2025-06-26 15:26:07 +00:00
Eunjae Lee 64092abdca fix: make uuid of App_RoutingForms_FormResponse nullable (#22056) 2025-06-26 10:42:45 +00:00
Sarthak KapilaandKartik Saini b7b631f0b0 fix: Persist filters across tabs to improve user experience (#21706)
* fix: Persist filters across tabs to improve user experience

* fixes unnecessary renders

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-06-26 10:04:53 +00:00
Anik Dhabal Babu c10f15e75b fix: google calendar event creation failure due to invalid timezone (#22048)
* fix: google calendar event creation failure

* update

* fix type error

* Update TimezoneSelect.tsx
2025-06-26 09:36:44 +00:00
Somay Chauhan 6983179b2f fix: handle existing oAuth client ID in user email to prevent duplication (#22021)
* fix: handle existing oAuth client ID in user email to prevent duplication

* fix: update OAuth user email validation
2025-06-26 08:29:29 +00:00
devin-ai-integration[bot]andeunjae@cal.com aea0661303 feat: disable changing field type of routing forms if responses exist (#21911)
* feat: add field type change warning dialog to routing forms

- Add FieldTypeChangeWarningDialog component to prevent field type changes
- Show informative dialog explaining data integrity concerns when users try to change field types
- Provide alternative suggestion to create new field instead of changing existing one
- Add i18n translations for dialog messages
- Integrate dialog into routing form edit page field type selector
- Bypass Teams plan restriction for testing routing forms functionality
- Fix ESLint error by properly handling scrollIntoView in embed mode with disable comment

Prevents data integrity issues by blocking field type changes (e.g. multiselect → text)
that could make existing form responses incompatible and cause data loss.

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

* fix: make field type dialog more selective to allow legitimate changes during form creation

- Only show warning dialog when there's actual data integrity risk
- Allow field type changes for new fields or fields without meaningful labels
- Fixes E2E test timeout by not blocking legitimate field type changes
- Maintains data protection for existing fields with real data

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

* fix: complete field type dialog implementation with translations and component

- Add FieldTypeChangeWarningDialog component with proper i18n support
- Add all required translation strings for dialog messages
- Ensure dialog only shows for actual data integrity risks
- Allow legitimate field type changes during form creation

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

* simplify field type dialog: one button, button replaces SelectField, remove forms page changes

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

* use Button component

* feat: restore SelectField with conditional dialog for field type changes

- Restore original SelectField dropdown to allow users to see available field types
- Add conditional logic to show dialog only when different type is selected AND form has responses
- Prevent field type changes by not calling onChange when dialog is shown
- Pass hasFormResponses prop from FormEdit to Field component
- Maintain data integrity while providing clear user guidance

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

* controlled value

* convert dialog to tooltip

* Update common.json

* remove unused texts

* allow type changing for new field

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-06-26 07:58:05 +00:00
Eunjae LeeandBenny Joo 3fd9ef366f fix: add InsightsBookingRepository (#22003)
* fix: add InsightsBookingRepository WIP

* write integration tests

* remove log

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-26 07:57:08 +00:00
GitHub Actions cf79a3f580 chore: release v5.4.7
Post release / release (push) Canceled after 0s
2025-06-25 23:25:56 +00:00
Joe Au-Yeung c5da78593a Fix import (#22020) 2025-06-25 23:15:04 +01:00
Benny Joo 801b0be414 perf: optimize fetch for primary google calendar (#22041)
* perf: optimize getting primary google calendar

* add test

* use calendars

* safer
2025-06-25 23:10:25 +01:00
RomitandBenny Joo 29aad2c535 fix: error because Seated slot reservations do not pass bookingUid (#22022)
* fixed query

* Update reserveSlot.handler.ts

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-25 21:56:32 +00:00
Carina WollendorferandCarinaWolli 82d3b9d29d only update reminders for EMAIL_HOST action (#22027)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-06-25 20:54:19 +00:00
devin-ai-integration[bot]andmorgan@cal.com a542ba486c feat: add graceful webhook secret validation to billing controller (#22034)
* feat: add graceful webhook secret validation to billing controller

- Wrap Stripe constructEventAsync in try-catch block
- Add proper error logging for failed webhook signature validation
- Return appropriate error responses for invalid webhook secrets
- Follow existing webhook handler patterns from payments module

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

* refactor: use specific Stripe error type for webhook signature validation

- Import Stripe types to access StripeSignatureVerificationError
- Check for specific Stripe signature verification error type
- Throw BadRequestException for invalid webhook signatures
- Maintain generic error handling for other webhook processing errors

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

* refactor: throw BadRequestException for missing webhook signature and secret

- Use BadRequestException instead of generic Error for missing stripe-signature header
- Use BadRequestException instead of generic Error for missing webhook secret configuration
- Consistent error handling with appropriate HTTP status codes

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>
2025-06-25 20:39:46 +00:00
Benny Joo d50e1a0e15 feat: Leverage ErrorCode cases for correct error codes in server responses (#22023)
* Leverage ErrorCode cases for correct error codes in server responses

* better test

* fix
2025-06-25 13:18:01 -07:00
Anik Dhabal Babu 79a0071d65 fix: e2e tests failure (#22043)
* fix: delete dilog button not visible

* fix: test failure
2025-06-25 15:59:03 -03:00
MorganandAlex van Andel 2f1f989266 feat: allow setting DAILY VIDEO region via env (#21939)
* feat: allow setting DAILY VIDEO region via env

* add comments

* Cleaner approach to Daily video region type assertion

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-25 18:01:40 +03:00
Eunjae Lee 2303eb7908 fix: add uuid to App_RoutingForms_FormResponse (#21910)
* fix: add uuid to App_RoutingForms_FormResponse

* update RoutingFormResponseDenormalized

* fix type error
2025-06-25 10:49:20 -04:00
Benny Joo c3666dd064 feat: correct error codes in responses for /social/og/image (#22015)
* fix error codes in og/image route

* test

* simplify
2025-06-25 11:24:34 -03:00
6ed197d35c fix: Org creation for self-hosters (#21994)
* Restart form if admin is not org admin

* Show pricing is admin and hosted

* Only show billing specific fields when enabled

* Create user onboarding only if valid valid license when self hosting

* Reset store when org creation is completed

* Create schema for trpc input when creating an org

* Add `organization.createSelfHosted` trpc endpoint

* `createOrganizationFromOnboarding` do not require stripe data

* Create organization without billing if self-hosted admin

* Update organizationOnboarding record as completed

* Handle updating subscription for self hosters

* Refactor `isBillingEnabled` to store

* Type fix

* Fix admin org creation for hosted

* Hide UI from admin

* Add tests

* fix: Fix LicenseKeySingleton mocking in organization tests

- Add proper vi.mock() module-level mocking for LicenseKeySingleton
- Replace incorrect casting syntax with vi.mocked() approach
- Add LicenseKeySingleton mock to hosted tests in intentToCreateOrg.handler.test.ts
- Fix IS_SELF_HOSTED mocking for hosted vs self-hosted test scenarios
- All organization creation tests now pass locally

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

* Fix tests

* Fix tests

* Test fixes

* Type fix

* Type fix

* Type fix

* Update packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts

Fix typo

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

* fix: Add IS_SELF_HOSTED mocking for E2E organization creation tests

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

* Fix E2E test

* Address feedback

* Type fix

* Fix e2e tests

* Remove unused code

* Small fixes

* fix unit tests

* review fixes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-25 16:58:18 +03:00
devin-ai-integration[bot]andmorgan@cal.com 164c9e37d7 feat: add /v2/billing/webhook route to RawBodyMiddleware configuration (#22032)
- Add /v2/billing/webhook alongside existing /api/v2/billing/webhook route
- Both routes now use RawBodyMiddleware for proper Stripe webhook signature verification
- Leverages NestJS forRoutes() method to accept multiple route configurations

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-06-25 15:21:56 +03:00
Hariom Balharaandcubic-dev-ai[bot] fa0a18b31d fix: Allow backgroundSlotsFetch option to disable slots fetching during prerender (#21999)
* fix: Allow noBackgroundSlotsFetch option to disable slots fetching
during prerender

* Update packages/embeds/embed-core/src/embed-iframe.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-25 10:38:35 +00:00
Benny Joo 80e2118e68 Revert "feat: Phone based bookings for everyone (#21320)" (#22018)
This reverts commit 866a0e2596.
2025-06-25 02:32:09 +00:00
GitHub Actions ed5086df6b chore: release v5.4.6
Post release / release (push) Canceled after 0s
2025-06-24 23:10:17 +00:00
Alex van Andel 218ec9140c Revert "perf: Refactor getPublicEvent to fetch org data using DB index key (#21850)"
This reverts commit a37f1d9798.
2025-06-24 16:37:36 +01:00
Hariom Balhara 6dad0e7679 fix-avoid-cross-domain-xhr (#22008) 2025-06-24 15:08:43 +00:00
Dhairyashil ShindeandKartik Saini cb96a56960 feat: Preserve past SMS workflow reminders by disabling deletion (#21906)
* Preserve past SMS workflow reminders by disabling deletion

* Restored code to delete SMS reminder worflows that fallback to EMAIL

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-06-24 12:01:08 +02:00
thegrasshopper104 b35eb77edb fixing hungarian translation (#21995) 2025-06-24 09:21:05 +00:00
Syed Ali Shahbaz 89408ce12b Update webhooks.mdx (#21900) 2025-06-24 07:50:07 +00:00
e22d9a551c fix: Theme class names for embed elements (#21275)
* refactor(embed-core): update theme class names for consistency

Standardize theme class names to use 'cal-element-embed-dark' and 'cal-element-embed-light' to improve maintainability and clarity.

* refactor(embed-core): replace theme class strings with constants

* fix theme not changing

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-24 07:26:48 +00:00
Carina WollendorferandCarinaWolli c215dc5864 fix: credit balance issues (#21904)
* fix creating user balance

* fix org check if teamId is given

* adjust hasAvailableCredits function

* improve comments

* fix creditBalance.create

* adjust test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-06-24 09:18:14 +02:00
Benny Joo a37f1d9798 perf: Refactor getPublicEvent to fetch org data using DB index key (#21850)
* wip

* fix

* fix naming

* fix typecheck
2025-06-23 23:01:06 +01:00
Benny Joo 07c8bbc562 fix: crash due to lack of input validation in WizardForm (#21990)
* fix: crash due to lack of input validation in WizardForm

* server side validation

* refactor

* refactor
2025-06-23 17:03:51 -03:00
Omar López b381cfe207 fix: Downgrades typescript to 5.7.2 (#21956) 2025-06-23 18:31:47 +00:00
Anik Dhabal Babu a560b9c8f4 fix: Add error log for zoom (#21991)
* fix: delete dilog button not visible

* fix: add log
2025-06-23 18:29:12 +00:00
Anik Dhabal Babu e5b8cc6912 fix: booking title translation issue (#21943)
* fix: add log to check organizer

* update

* Update CalendarService.ts
2025-06-23 18:23:31 +00:00
Somay ChauhanandRajiv Sahal 18b5612c0d feat: add apps filter and bulk update toggle to conferencing apps atom (#21973)
* feat: add apps filter and bulk update toggle to conferencing apps atom

* docs: clarify description of disableBulkUpdateEventTypes flag in conferencing apps docs

* docs: update valid conferencing app slugs in atoms documentation

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-06-23 22:59:44 +05:30
Anik Dhabal Babu fad488623f Revert "fix: multi-line formatted correctly for sms (#21779)" (#21979)
This reverts commit 5e2f06cdea.
2025-06-23 16:00:43 +00:00
Romit.andAnik Dhabal Babu c490f084dc fix: background of event advanced questions section in dark theme (#21985)
* fixed bg of event advanced questions

* Update EventAdvancedTab.tsx

* Update EventAdvancedTab.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-23 15:33:32 +00:00
Joe Au-Yeung b1caacd643 Disable tRPC endpoint on slots fetch failure (#21986) 2025-06-23 15:17:33 +00:00
Udit Takkar 76c8876923 fix: don't fetch calendar for top banner (#21908) 2025-06-23 09:00:44 -03:00
Hariom Balhara a911c9cb8d Make props optiona (#21977) 2025-06-23 11:52:10 +00:00
devin-ai-integration[bot]andeunjae@cal.com 07d5594085 feat: add integration test for RoutingFormResponseField triggers (#21586)
* feat: add integration test for RoutingFormResponseField triggers

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

* refactor: remove changing field types test case

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-06-23 10:11:20 +00:00
Benny JooandAnik Dhabal Babu a745d8a60a fix: error in /auth/session (#21972)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-23 08:32:14 +00:00
Dhairyashil ShindeandSomay Chauhan 8b83d39ac3 feat: Add onTimeslotsLoaded prop to Booker atom (#21871)
* [CAL-5926] Add 'onTimeslotsLoaded' prop to 'Booker' atom

* Added 'onTimeslotsLoaded' to BookerPlatformWrapperAtomProps

* fixed type error and added onTimeslotsLoaded prop to doc

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-06-23 08:13:36 +00:00
4edb39616d feat: download credit expense log button (#21771)
* add smsSegments to creditExpenseLog

* add download button

* download only data from selected month

* improvements

* improve design

* use repository function

* add tests for credit repository

* fix type error

* fix unit test

* fix type error

* fix unit test

* code clean up

* fix skeleton loader

* remove empty mt

* code clean up

* move csv headers out of function

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-23 03:57:32 -04:00
Somay ChauhanandAnik Dhabal Babu 7a2060fe08 fix: dont expose credential.key in api output (#21970)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-23 07:09:44 +00:00
Omar López d009682d4a Revert "perf: deduplicating DB fetches + leverage unique DB index for team ty…" (#21969)
This reverts commit 7900be3a30.
2025-06-23 04:12:20 +00:00
Amit Sharma 8adb720a80 fix: Double skeleton apps page (#21965) 2025-06-22 18:24:16 -04:00
Omar López 4d6f338f0c Revert "Update draft-release.yml (#21932)" (#21966)
This reverts commit 04e60611ed.
2025-06-22 19:50:46 +00:00
Morgan ffe9d43d09 chore: better trpc error handling (#21948) 2025-06-21 11:56:23 +05:30
Alex van AndelandOmar López 2227b4ff91 chore: Lower the threshold for eager token expiration (#20964)
Co-authored-by: Omar López <zomars@me.com>
2025-06-20 14:33:23 -04:00
Benny JooandKeith Williams 5dd4937a1a chore: add e2e test for ai translation (#18061)
* make createNewEventType receive description arg

* remove @test tag

* wip

* wip2

* remove @test tag

* modify EventMetaProps

* add test id event-meta-description

* improve test

* better name

* try

* finalized

* fix

* fix type check

* address comment

* cleann up

* cleann up

* fix

* Update ai-translation.e2e.ts

* fix

* simplify

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-06-20 23:00:39 +05:30
866a0e2596 feat: Phone based bookings for everyone (#21320)
* feat: Phone based bookings for everyone

* chore: save progress

* feat: add UI

* chore: UI

* chore: remove sms notification

* chore: remove none

* chore: remove ligs

* chore: remove log

* test: add unit tests

* test: add unit tests

* chore: cache _isSMSNotificationEnabled

* chore: cache _isSMSNotificationEnabled

* refactor: get value from fields

* fix: type err

* chore: also pass  user id

* fix: unit test for sms manager

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-06-20 12:42:32 -04:00
GitHub Actions 64bfda9dbe chore: release v5.4.5
Post release / release (push) Canceled after 0s
2025-06-20 14:53:05 +00:00
Udit TakkarandHariom aacbbc1785 fix: Rate limit /router endpoint (#21940)
* fix: add rate limiting for /router/embed

* Abstract out hashing code

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-20 14:47:56 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] 3714d5faab fix: routing form redirects to use app.cal.com instead of cal.com (#21924)
* fix: routing form redirects to use app.cal.com instead of cal.com

* Update packages/lib/hooks/useBookerUrl.ts

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

* update

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-20 17:22:53 +05:30
Kartik Saini 12cb94950a fix: ui (#21936) 2025-06-20 10:16:09 +00:00
Somay Chauhan 795fd788e2 feat: add response status to calendar event host details (#21934)
* feat: add response status to calendar event host details

* docs: update calendar event response status description from attendee to host
2025-06-20 07:23:18 +00:00
devin-ai-integration[bot]andeunjae@cal.com 956fd6ae2f feat: add attendee data to CSV export with seated event support (#21842)
* feat: add attendee data to CSV export with seated event support

- Add up to 3 attendees to CSV export in 'name (email)' format
- Use booking.seatsReferences for seated events, booking.attendees for regular events
- Maintain backward compatibility with existing bookerEmail/bookerName fields
- Add attendee1, attendee2, attendee3 columns to CSV output

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

* fix: restore missing comment as requested in PR review

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

* Update packages/features/insights/server/events.ts

* feat: remove duplicate booker columns from CSV export

- Remove bookerEmail and bookerName columns as they duplicate attendee data
- Remove noShowGuest field which was based on booker data
- Remove unused booker field from bookingMap
- Keep only attendee1, attendee2, attendee3 columns with name (email) format

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

* feat: restore noShowGuest field to CSV export

- Add noShowGuest field mapping from first attendee's noShow property
- Maintain existing attendee1, attendee2, attendee3 columns
- Default to false when no attendees exist
- Works with both seated events and regular events

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

* rename column

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-06-20 08:11:08 +01:00
Omar López 04e60611ed Update draft-release.yml (#21932) 2025-06-20 03:31:45 +00:00
GitHub Actions bc99e9e504 chore: release v5.4.4
Post release / release (push) Canceled after 0s
2025-06-19 21:18:47 +00:00
FaizandKartik Saini 2f35428232 fix: align tick icon, text, and cross icon inside toaster (#21607)
* Fix: align tick icon, text, and cross icon inside toaster

* fix: align elements in toaster

* fix: toaster icon changes

* test approval recheck

* fix: toast message id fixed

* fix: merge conflict resolved

* fix:test recheck

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-06-19 16:27:35 +00:00
Adarsh Tiwari 00aac89ed7 fix:multiple buttons (#21928) 2025-06-19 15:08:47 +00:00
Anik Dhabal Babu f390e91bd7 fix: can save an event type without a slug (#21922) 2025-06-19 15:03:58 +00:00
Ayush Mukkanwar e413359c8c fix(i18n): correct Serbian translation for scheduled event (#21235) (#21925)
* fix(i18n): correct Serbian translation for scheduled event (#21235)

* fix(i18n): normalize all Serbian translations to use correct 'događaj' forms
2025-06-19 20:09:40 +05:30
Anik Dhabal Babu 601df7f808 fix: Slots are available even after exceeding the booking limits for the team event (#21801)
* fix: slots available

* fix type error

* update

* update
2025-06-19 20:05:43 +05:30
Syed Ali Shahbazandsupalarry e160a4add0 feat: Schedule restriction and booker timezone (#21452)
* init migration and fix name for team event availability toggle

* show and save restriction schedule

* better naming around

* fix loading stored restricted schedule instead of default

* add the booker timezone checkbox in the form

* restriction schedule application babbbyyyy

* remove logs

* test

* typefix

* fix

* fix??

* fix???

* ---

* typefix

* functional fix

* restrictionschedule logic --1

* timezone adjustment for regular constraint

* noice

* test suite for restrictionSchedule

* auth restrictionschedule for eventtype

* remove unnecessary comments

* index

* type fix

* add concurrent

* resolve change request

* fix unauth erro

* type fix

* schedule select extracted from select

* revert

* fix auth vulnerabililty

* fix e22

* chore: hide restriction schedule on platform

* init review feedback resolutions

* fixes

* fix type

* multiple windows of recurring rule-same day

* team feature flag

* fix type err

* fix type

* improvements

* fix test

* improve

* fix error propagation

* fix unused var lint

* cleanup and using buildDateRanges

* travel schedule inclusion

* address comment

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-06-19 14:08:51 +01:00
GitHub Actions 3bea03f4b1 chore: release v5.4.3
Post release / release (push) Canceled after 0s
2025-06-19 07:57:24 +00:00
Hariom Balhara 19dbde8a3c Add comment (#21920) 2025-06-19 05:55:42 +00:00
Syed Ali Shahbaz a5fe10189f fix: Banner uploader fix error handling and reduce image size jump (#21903)
* improve uploader

* better error handling

* unnecessary check

* more adjustments

* remove logs
2025-06-19 03:35:41 +00:00
Adarsh Tiwari 7075384313 fix:hover on delete action on event-types (#21884) 2025-06-18 21:47:47 +00:00
Udit Takkar 25bf43cddd perf: improve invalid app credential query (#21909) 2025-06-19 02:29:33 +05:30
Udit Takkar 2880a681a8 feat: disable transcription setting in cal video (#21755)
* feat: disable transcription setting in cal video

* fix: type err
2025-06-18 14:36:36 +00:00
bbcd0b7e78 test: add unit tests to various important fns in routing flow (#21902)
* Add unit tests to routing flow

* fix: resolve TypeScript errors in routing forms test files

- Fix mock form structure to match TargetRoutingFormForResponse type
- Add missing properties like selectText, deleted, updatedById to form fields
- Correct route action types to use valid RouteActionType enum values
- Fix response objects to include required label property
- Update mock return values for findTeamMembersMatchingAttributeLogic
- Use proper type assertions for complex route objects

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-06-18 14:14:14 +00:00
Adarsh Tiwari 916ed13bea fix the height of buttons in override list (#21898) 2025-06-18 12:41:28 +00:00
Alex van Andel 11a06324be fix: 'handles OOO correctly' test fail in non-UTC servertime (#21899) 2025-06-18 12:28:32 +01:00
Hariom Balhara bcb340d97e fix: Dry Run for non-pre-render scenario (#21893)
* Fix dry-run non-prerender scenario

* Add test

* Enable logging through cal.embed.logging=1
2025-06-18 10:27:44 +01:00
Somay Chauhanandcubic-dev-ai[bot] aabef47ff1 fix: microsoft oAuth in api/v2 (#21890)
* fix: microsoft oAuth in api/v2

* Update apps/api/v2/src/ee/calendars/services/gcal.service.ts

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

* Update gcal.service.ts

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-18 07:34:42 +00:00
Benny Joo e27de47d9f perf: leverage DB index for user profile fetching (booking page & payment page) (#21814)
* leverage DB index for findByUserIdAndOrgSlug method

* booking view

* payment view

* fix

* fix

* address comment
2025-06-18 09:53:12 +03:00
Benny Joo 7900be3a30 perf: deduplicating DB fetches + leverage unique DB index for team type booking page (#21811)
* wip

* refactor

* refactor

* fix

* optimize

* better

* comment

* better code

* do not use cache for now

* simplify
2025-06-18 09:53:04 +03:00
Joe Au-YeungandOmar López ddc5838c7b Change to OR operator (#21881)
Co-authored-by: Omar López <zomars@me.com>
2025-06-17 23:04:38 +00:00
6a13d7c5c6 fix: update trigger function for RoutingFormResponseField (#21868)
* fix: update trigger function for RoutingFormResponseField

* Update packages/prisma/migrations/20250617123959_update_handle_routing_form_response_fields/migration.sql

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

* add reusable function

* handle number type more flexibly

* handle multiselect value flexibly

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-17 20:47:43 +02:00
332 changed files with 14387 additions and 3596 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"ignore": ["@calcom/platform-libraries"],
"privatePackages": {
"version": false,
"tag": false
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
fix saving event type settings
+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=
+1 -1
View File
@@ -24,7 +24,7 @@
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
/packages/prisma/**/* @calcom/DBA
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below
name: Release
runs-on: ubuntu-latest
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: Checkout Repo
uses: actions/checkout@v4
+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"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
View File
View File
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
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"
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1
View File
@@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "",
"main": "index.js",
"private": true,
"scripts": {
"dev": "node ./index.js"
},
+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",
+2 -2
View File
@@ -33,12 +33,12 @@
"@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",
"next-validations": "^0.2.0",
"typescript": "^5.8.3",
"typescript": "^5.7.2",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
+3 -3
View File
@@ -4,6 +4,7 @@
"description": "Platform API for Cal.com",
"author": "Cal.com Inc.",
"license": "UNLICENSED",
"private": true,
"scripts": {
"build": "yarn dev:build && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -37,8 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
"@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": "*",
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.8.3"
"typescript": "^5.7.2"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
+10 -4
View File
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(RawBodyMiddleware)
.forRoutes({
path: "/api/v2/billing/webhook",
method: RequestMethod.POST,
})
.forRoutes(
{
path: "/api/v2/billing/webhook",
method: RequestMethod.POST,
},
{
path: "/v2/billing/webhook",
method: RequestMethod.POST,
}
)
.apply(JsonBodyMiddleware)
.forRoutes("*")
.apply(RequestIdMiddleware)
@@ -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;
@@ -54,6 +54,23 @@ import {
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
export interface CalendarState {
accessToken: string;
origin: string;
redir?: string;
isDryRun?: boolean;
}
const calendarStateSchema = z.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().optional(),
isDryRun: z
.string()
.optional()
.transform((val) => val === "true"),
});
@Controller({
path: "/v2/calendars",
version: API_VERSIONS_VALUES,
@@ -181,32 +198,31 @@ export class CalendarsController {
@Query("code") code: string,
@Param("calendar") calendar: string
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin, redir, isDryRun } = z
.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().nullish().optional(),
isDryRun: z.string().nullish().optional(),
})
.parse({
let stateObj: CalendarState;
try {
// First try to parse as JSON
stateObj = JSON.parse(state) as CalendarState;
} catch (e) {
// If JSON parsing fails, try URL params
const stateParams = new URLSearchParams(state);
const parsedState = calendarStateSchema.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
redir: stateParams.get("redir") || undefined,
isDryRun: stateParams.get("isDryRun"),
});
stateObj = parsedState;
}
const { accessToken, origin, redir, isDryRun } = stateObj;
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
return await this.outlookService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(
code,
accessToken,
origin,
redir ?? "",
isDryRun === "true"
);
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -1,4 +1,5 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import type { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -65,12 +66,18 @@ export class GoogleCalendarService implements OAuthCalendarApp {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const state: CalendarState = {
accessToken,
origin,
redir,
isDryRun,
};
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
state: JSON.stringify(state),
});
return authUrl;
@@ -1,4 +1,5 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -60,6 +61,13 @@ export class OutlookService implements OAuthCalendarApp {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const state: CalendarState = {
accessToken,
origin,
redir,
isDryRun,
};
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
const params = {
response_type: "code",
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
state: JSON.stringify(state),
};
const query = stringify(params);
@@ -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.";
+104 -71
View File
@@ -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";
@@ -6,6 +7,100 @@ import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
import { Response } from "@calcom/platform-types";
export type TRPCErrorCode = TRPCError["code"];
export interface ErrorDefinition {
statusCode: number;
message: string;
}
// Define the specific TRPC error codes
export const TRPC_ERROR_CODE = {
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
NOT_FOUND: "NOT_FOUND",
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
BAD_REQUEST: "BAD_REQUEST",
CONFLICT: "CONFLICT",
TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
CLIENT_CLOSED_REQUEST: "CLIENT_CLOSED_REQUEST",
METHOD_NOT_SUPPORTED: "METHOD_NOT_SUPPORTED",
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
PRECONDITION_FAILED: "PRECONDITION_FAILED",
TIMEOUT: "TIMEOUT",
UNPROCESSABLE_CONTENT: "UNPROCESSABLE_CONTENT",
PARSE_ERROR: "PARSE_ERROR",
} as const; // `as const` ensures literal types, improving type inference
// Map TRPC error codes to their HTTP status codes and default messages
export const TRPC_ERROR_MAP: Record<(typeof TRPC_ERROR_CODE)[keyof typeof TRPC_ERROR_CODE], ErrorDefinition> =
{
[TRPC_ERROR_CODE.UNAUTHORIZED]: {
statusCode: 401,
message: "You are not authorized to access this resource",
},
[TRPC_ERROR_CODE.FORBIDDEN]: {
statusCode: 403,
message: "You don't have necessary permissions to access this resource",
},
[TRPC_ERROR_CODE.NOT_FOUND]: {
statusCode: 404,
message: "The requested resource was not found",
},
[TRPC_ERROR_CODE.INTERNAL_SERVER_ERROR]: {
statusCode: 500,
message: "An unexpected error occurred on the server. Please try again later",
},
[TRPC_ERROR_CODE.BAD_REQUEST]: {
statusCode: 400,
message: "Bad request: Please check your request data and try again",
},
[TRPC_ERROR_CODE.CONFLICT]: {
statusCode: 409,
message: "Could not process the request due to a resource conflict with the current state",
},
[TRPC_ERROR_CODE.TOO_MANY_REQUESTS]: {
statusCode: 429,
message: "You have exceeded the allowed number of requests",
},
[TRPC_ERROR_CODE.PAYLOAD_TOO_LARGE]: {
statusCode: 413,
message: "The request payload is too large",
},
[TRPC_ERROR_CODE.CLIENT_CLOSED_REQUEST]: {
statusCode: 499,
message: "The client closed the connection before the server could finish processing the request",
},
[TRPC_ERROR_CODE.METHOD_NOT_SUPPORTED]: {
statusCode: 405,
message: "The requested method is not supported for this resource",
},
[TRPC_ERROR_CODE.NOT_IMPLEMENTED]: {
statusCode: 501,
message: "The requested method is not implemented on the server",
},
[TRPC_ERROR_CODE.PRECONDITION_FAILED]: {
statusCode: 412,
message: "The server does not meet one of the preconditions that the requester put on the request",
},
[TRPC_ERROR_CODE.TIMEOUT]: {
statusCode: 408,
message: "The request took too long to complete. Please try again later",
},
[TRPC_ERROR_CODE.UNPROCESSABLE_CONTENT]: {
statusCode: 422,
message:
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect",
},
[TRPC_ERROR_CODE.PARSE_ERROR]: {
statusCode: 400,
message: "The request could not be parsed due to invalid syntax.",
},
};
// --- TRPCExceptionFilter (updated) ---
@Catch(TRPCError)
export class TRPCExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("TRPCExceptionFilter");
@@ -15,81 +110,18 @@ export class TRPCExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode = 500;
let errorMessage = exception.message;
const errorDefinition = TRPC_ERROR_MAP[exception.code as keyof typeof TRPC_ERROR_CODE] || {
statusCode: 500,
message: exception.message,
};
switch (exception.code) {
case "UNAUTHORIZED":
statusCode = 401;
errorMessage = "You are not authorized to access this resource";
break;
case "FORBIDDEN":
statusCode = 403;
errorMessage = "You don't have necessary permissions to access this resource";
break;
case "NOT_FOUND":
statusCode = 404;
errorMessage = "The requested resource was not found";
break;
case "INTERNAL_SERVER_ERROR":
statusCode = 500;
errorMessage = "An unexpected error occurred on the server. Please try again later";
break;
case "BAD_REQUEST":
statusCode = 400;
errorMessage = "Bad request: Please check your request data and try again";
break;
case "CONFLICT":
statusCode = 409;
errorMessage = "Could not process the request due to a resource conflict with the current state";
break;
case "TOO_MANY_REQUESTS":
statusCode = 429;
errorMessage = "You have exceeded the allowed number of requests";
break;
case "PAYLOAD_TOO_LARGE":
statusCode = 413;
errorMessage = "The request payload is too large";
break;
case "CLIENT_CLOSED_REQUEST":
statusCode = 499;
errorMessage =
"The client closed the connection before the server could finish processing the request";
break;
case "METHOD_NOT_SUPPORTED":
statusCode = 405;
errorMessage = "The requested method is not supported for this resource";
case "NOT_IMPLEMENTED":
statusCode = 501;
errorMessage = "The requested method is not implemented on the server";
break;
case "PRECONDITION_FAILED":
statusCode = 412;
errorMessage =
"The server does not meet one of the preconditions that the requester put on the request";
break;
case "TIMEOUT":
statusCode = 408;
errorMessage = "The request took too long to complete. Please try again later";
break;
case "UNPROCESSABLE_CONTENT":
statusCode = 422;
errorMessage =
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect";
break;
case "PARSE_ERROR":
statusCode = 400;
errorMessage = "The request could not be parsed due to invalid syntax.";
break;
default:
statusCode = 500;
errorMessage = exception.message;
break;
}
const statusCode = errorDefinition.statusCode;
const errorMessage = errorDefinition.message;
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,
@@ -97,13 +129,14 @@ export class TRPCExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
error: { code: exception.name, message: errorMessage },
error: { code: exception.name, message: errorMessage }, // exception.name is usually "TRPCError"
});
}
}
@@ -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;
}
+10 -21
View File
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
this.logger.verbose(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
}
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit(tracker);
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
${JSON.stringify(rateLimit, null, 2)}
`);
`);*/
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) {
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
${cachedRateLimits}
`);
`);*/
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
}
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
select: { name: true, limit: true, ttl: true, blockDuration: true },
});
if (rateLimits) {
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
${JSON.stringify(rateLimits, null, 2)}`);
}
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit(tracker)];
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);*/
}
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.verbose(
/*this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
);
this.logger.verbose(
);*/
/*this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" response headers:
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
);
);*/
return { isBlocked };
}
@@ -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,
});
})
);
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -21,10 +21,12 @@ import {
HttpStatus,
Logger,
Delete,
BadRequestException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import Stripe from "stripe";
import { ApiResponse } from "@calcom/platform-types";
@@ -116,35 +118,59 @@ export class BillingController {
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService
.getStripe()
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
try {
if (!stripeSignature) {
this.logger.warn("Missing stripe-signature header in webhook request");
return {
status: "success",
};
}
switch (event.type) {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.updated":
await this.billingService.handleStripePaymentPastDue(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.created":
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
if (!this.stripeWhSecret) {
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
return {
status: "success",
};
}
const event = await this.billingService.stripeService
.getStripe()
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
switch (event.type) {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.updated":
await this.billingService.handleStripePaymentPastDue(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.created":
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
}
return {
status: "success",
};
} catch (error) {
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
this.logger.error("Webhook signature validation failed", error);
return {
status: "success",
};
}
throw error;
}
return {
status: "success",
};
}
}
@@ -211,6 +211,18 @@ export class CalendarEventHost {
description: "Display name of the event host",
})
name?: string;
@IsString()
@IsOptional()
@IsEnum(CalendarEventResponseStatus)
@ApiPropertyOptional({
enum: CalendarEventResponseStatus,
nullable: true,
enumName: "CalendarEventResponseStatus",
description: "Host's response to the invitation",
example: CalendarEventResponseStatus.ACCEPTED,
})
responseStatus!: CalendarEventResponseStatus | null;
}
export class CalendarEventAttendee {
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
}
}
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
if (googleEvent.organizer) {
return [
{
email: googleEvent.organizer.email,
name: googleEvent.organizer.displayName,
},
];
} else if (googleEvent.attendees) {
// If no explicit organizer, find the first attendee with organizer flag
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
if (organizerAttendee) {
return [
{
email: organizerAttendee.email,
name: organizerAttendee.displayName,
},
];
}
private transformHosts(googleEvent: GoogleCalendarEventResponse) {
const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
[];
const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
if (organizerAttendees?.length) {
organizerAttendees.forEach((organizer) => {
hosts.push({
email: organizer.email,
name: organizer.displayName,
responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
});
});
} else {
hosts.push({
email: googleEvent.organizer.email,
name: googleEvent.organizer.displayName,
responseStatus: null,
});
}
return [];
return hosts;
}
private transformLocations(
@@ -1,6 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
@@ -8,12 +9,26 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
export class ConferencingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
id: true,
type: true,
userId: true,
teamId: true,
appId: true,
subscriptionId: true,
paymentStatus: true,
billingCycleStart: true,
invalid: true,
delegationCredentialId: true,
});
async findConferencingApps(userId: number) {
return this.dbRead.prisma.credential.findMany({
where: {
userId,
type: { endsWith: "_video" },
},
select: this.credentialSelect,
});
}
@@ -23,24 +38,28 @@ export class ConferencingRepository {
teamId,
type: { endsWith: "_video" },
},
select: this.credentialSelect,
});
}
async findGoogleMeet(userId: number) {
return this.dbRead.prisma.credential.findFirst({
where: { userId, type: GOOGLE_MEET_TYPE },
select: this.credentialSelect,
});
}
async findConferencingApp(userId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({
where: { userId, appId: app },
select: this.credentialSelect,
});
}
async findTeamConferencingApp(teamId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({
where: { teamId, appId: app },
select: this.credentialSelect,
});
}
}
@@ -144,6 +144,9 @@ export class OAuthClientUsersService {
}
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
if (userEmail.includes(`+${oAuthClientId}@`)) {
return userEmail;
}
const [username, emailDomain] = userEmail.split("@");
return `${username}+${oAuthClientId}@${emailDomain}`;
}
@@ -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,
},
});
}
@@ -1,3 +1,4 @@
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
@@ -15,6 +16,7 @@ import {
VERSION_2024_06_11,
VERSION_2024_08_13,
} from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
@@ -209,7 +211,12 @@ export class SlotsController_2024_04_15 {
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
);
}
if (TRPC_ERROR_MAP[error.message as keyof typeof TRPC_ERROR_CODE]) {
throw new TRPCError({ code: error.message as TRPCErrorCode });
}
}
throw error;
}
}
+6 -1
View File
@@ -26400,7 +26400,7 @@
},
"CalendarEventResponseStatus": {
"type": "string",
"description": "Attendee's response to the invitation",
"description": "Host's response to the invitation",
"enum": [
"accepted",
"pending",
@@ -26460,6 +26460,11 @@
"type": "string",
"nullable": true,
"description": "Display name of the event host"
},
"responseStatus": {
"nullable": true,
"example": "accepted",
"$ref": "#/components/schemas/CalendarEventResponseStatus"
}
},
"required": [
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
import { TooltipExample } from "./avatar.tooltip"
import { LinkExample } from "./avatar.link"
import { IndicatorExample } from "./avatar.indicator"
import { ShapeExample } from "./avatar.shape"
## Size Variations
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
<SizesExample/>
## Shape Variations
Avatars can be displayed in either circular or square shapes.
<ShapeExample/>
## With and Without Image
Avatars can display either an image or a fallback placeholder.
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
Avatars can include status indicators or badges.
<IndicatorExample/>
## Usage Example
Here's a complete example showing how to use the Avatar component with all its props:
```tsx
import { Avatar } from "@calcom/ui/components/avatar";
function Example() {
return (
<div className="flex flex-col gap-4">
{/* Basic Avatar */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
imageSrc="https://example.com/avatar.jpg"
/>
{/* Avatar with Link */}
<Avatar
alt="User Name"
title="User Name"
size="lg"
shape="square"
imageSrc="https://example.com/avatar.jpg"
href="/profile"
/>
{/* Avatar with Tooltip and Indicator */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
imageSrc="https://example.com/avatar.jpg"
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
/>
{/* Avatar with Fallback */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
fallback={<span>UN</span>}
/>
</div>
);
}
```
@@ -0,0 +1,24 @@
import { Avatar } from "@calcom/ui/components/avatar";
export function ShapeExample() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Avatar
alt="Circle Avatar"
title="Circle Avatar"
size="lg"
shape="circle"
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
/>
<Avatar
alt="Square Avatar"
title="Square Avatar"
size="lg"
shape="square"
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
/>
</div>
</div>
);
}
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -21,11 +23,18 @@ export const generateMetadata = async () => {
};
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const stepSchema = z.enum(["1", "2", "3", "4"]);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
const ServerPage = async ({ params, searchParams: _searchParams }: ServerPageProps) => {
const searchParams = await _searchParams;
const stepResult = stepSchema.safeParse(searchParams?.step);
// If step parameter is invalid, redirect to step 1
if (!stepResult.success) {
return redirect(`/auth/setup?step=1`);
}
const props = await getData(buildLegacyCtx(await headers(), await cookies(), await params, searchParams));
return <Setup {...props} />;
};
@@ -46,13 +46,13 @@ async function postHandler(req: NextRequest) {
if (!log.smsSid) return;
try {
const price = await twilio.getPriceForSMS(log.smsSid);
const { price, numSegments } = await twilio.getMessageInfo(log.smsSid);
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
if (!credits) return;
const updatedLog = await prisma.creditExpenseLog.update({
where: { id: log.id },
data: { credits },
data: { credits, smsSegments: numSegments },
select: {
creditBalance: {
select: {
@@ -8,11 +8,11 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
// Mock GoogleCalendarService
const fetchPrimaryCalendarMock = vi.fn();
const getPrimaryCalendarMock = vi.fn();
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
return {
default: vi.fn().mockImplementation(() => ({
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
getPrimaryCalendar: getPrimaryCalendarMock,
})),
};
});
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
prismock.user.deleteMany();
prismock.team.deleteMany();
prismock.workspacePlatform.deleteMany();
fetchPrimaryCalendarMock.mockReset();
getPrimaryCalendarMock.mockReset();
});
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
@@ -138,7 +138,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
getPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
delegationCredentialId: delegationCredential.id,
credentialId: 1,
});
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(0);
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
error: "some error" as unknown as null,
credentialId: regularCredential.id,
});
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockRejectedValue(
getPrimaryCalendarMock.mockRejectedValue(
new CalendarAppDelegationCredentialInvalidGrantError("some error")
);
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
getPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(0);
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
const userEmail = delegationUserCredential.user.email;
try {
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
primaryCalendarId = primaryCalendar?.id;
} catch (error) {
log.error(
@@ -0,0 +1,110 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { GET } from "../route";
vi.mock("next/og", () => ({
ImageResponse: vi.fn().mockImplementation(() => ({
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
controller.close();
},
}),
})),
}));
vi.mock("@calcom/lib/OgImages", () => ({
Meeting: vi.fn(() => null),
App: vi.fn(() => null),
Generic: vi.fn(() => null),
}));
vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
global.fetch = vi.fn();
function createNextRequest(url: string): NextRequest {
const request = new Request(url, { method: "GET" });
return new NextRequest(request);
}
describe("GET /api/social/og/image", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(global.fetch).mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
} as any);
});
describe("Validation errors (400 Bad Request)", () => {
test("meeting type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
const response = await GET(request);
expect(response.status).toBe(400);
expect(response.headers.get("Content-Type")).toBe("application/json");
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for meeting image");
expect(errorData.message).toBe(
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
);
});
test("app type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
const response = await GET(request);
expect(response.status).toBe(400);
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for app image");
expect(errorData.message).toBe("Required parameters: name, description, slug");
});
test("generic type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
const response = await GET(request);
expect(response.status).toBe(400);
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for generic image");
expect(errorData.message).toBe("Required parameters: title, description");
});
});
describe("Not found errors (404 Not Found)", () => {
test("returns 404 when no type parameter is provided", async () => {
const request = createNextRequest("http://example.com/api/social/og/image");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
});
test("returns 404 when invalid type parameter is provided", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
});
});
describe("Server errors (500 Internal Server Error)", () => {
test("returns 500 when font loading fails", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
const request = createNextRequest(
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
);
const response = await GET(request);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Internal server error");
});
});
});
+119 -66
View File
@@ -1,7 +1,7 @@
import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server";
import type { SatoriOptions } from "satori";
import { z } from "zod";
import { z, ZodError } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
const { searchParams } = req.nextUrl;
const imageType = searchParams.get("type");
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]);
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
};
try {
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]);
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
};
switch (imageType) {
case "meeting": {
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
names: searchParams.getAll("names"),
usernames: searchParams.getAll("usernames"),
title: searchParams.get("title"),
meetingProfileName: searchParams.get("meetingProfileName"),
meetingImage: searchParams.get("meetingImage"),
imageType,
});
switch (imageType) {
case "meeting": {
try {
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
names: searchParams.getAll("names"),
usernames: searchParams.getAll("usernames"),
title: searchParams.get("title"),
meetingProfileName: searchParams.get("meetingProfileName"),
meetingImage: searchParams.get("meetingImage"),
imageType,
});
const img = new ImageResponse(
(
<Meeting
title={title}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
),
ogConfig
);
const img = new ImageResponse(
(
<Meeting
title={title}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
),
ogConfig
);
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
});
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for meeting image",
message:
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
case "app": {
try {
const { name, description, slug } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for app image",
message: "Required parameters: name, description, slug",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
case "generic": {
try {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for generic image",
message: "Required parameters: title, description",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
}
case "app": {
const { name, description, slug } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
case "generic": {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
} catch (error) {
return new Response("Internal server error", { status: 500 });
}
}
@@ -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">
+4 -1
View File
@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
const { t } = useLocale();
const router = useRouter();
const bookerUrl = useBookerUrl();
const session = useSession();
const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data
?.map((member) => member.id)
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
booking,
currentResponse,
searchParams: new URLSearchParams({
// rescheduleReason
rescheduledBy: session?.data?.user?.email ?? "",
}),
});
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
createBookingMutation.mutate({
rescheduleUid: booking.uid,
rescheduledBy: session?.data?.user?.email ?? undefined,
// rescheduleReason,
reroutingFormResponses: reroutingFormResponses,
...getTimeslotFields(),
@@ -1,5 +1,7 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { vi } from "vitest";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
vi.stubGlobal("open", mockOpen);
const mockSession = {
expires: new Date(Date.now() + 2 * 86400).toISOString(),
user: {
id: 1,
name: "Test User",
email: "user@example.com",
},
} as Session;
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
return (
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
describe("New Routing tests", () => {
test("when verify_new_route is clicked, the form is submitted", async () => {
render(
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
);
fireEvent.click(screen.getByText("verify_new_route"));
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
describe("New tab rescheduling", () => {
test("new tab is closed when new booking is rerouted", async () => {
render(
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
);
clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
test("Rescheduling with same timeslot works", async () => {
render(
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
);
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
@@ -105,7 +105,7 @@ const CustomEmailTextField = ({
<DropdownItem
StartIcon="trash"
color="destructive"
className="disabled:opacity-40"
className="rounded-t-none disabled:opacity-40"
onClick={handleItemDelete}
disabled={emailPrimary}
data-testid="secondary-email-delete-button">
+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}`,
@@ -23,6 +23,8 @@ type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
enableAutomaticTranscription: boolean;
disableTranscriptionForGuests: boolean;
disableTranscriptionForOrganizer: boolean;
};
const shouldEnableRecordButton = ({
@@ -57,6 +59,25 @@ const shouldEnableAutomaticTranscription = ({
return !!calVideoSettings.enableAutomaticTranscription;
};
const shouldEnableTranscriptionButton = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan) return false;
if (!calVideoSettings) return true;
if (isOrganizer) {
return !calVideoSettings.disableTranscriptionForOrganizer;
}
return !calVideoSettings.disableTranscriptionForGuests;
};
const checkIfUserIsHost = async ({
booking,
sessionUserId,
@@ -222,6 +243,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
});
const showTranscriptionButton = shouldEnableTranscriptionButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
});
return {
props: {
@@ -245,6 +271,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
enableAutomaticTranscription,
showTranscriptionButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
@@ -1,6 +1,7 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useSearchParams } from "next/navigation";
import { useMemo, useRef } from "react";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
@@ -44,34 +45,6 @@ type RecurringInfo = {
bookings: { [key: string]: Date[] };
};
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "upcoming",
href: "/bookings/upcoming",
"data-testid": "upcoming",
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
"data-testid": "unconfirmed",
},
{
name: "recurring",
href: "/bookings/recurring",
"data-testid": "recurring",
},
{
name: "past",
href: "/bookings/past",
"data-testid": "past",
},
{
name: "cancelled",
href: "/bookings/cancelled",
"data-testid": "cancelled",
},
];
const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const tableContainerRef = useRef<HTMLDivElement>(null);
const searchParams = useSearchParams();
// Generate dynamic tabs that preserve query parameters
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
const queryString = searchParams?.toString() || "";
const baseTabConfigs = [
{
name: "upcoming",
path: "/bookings/upcoming",
"data-testid": "upcoming",
},
{
name: "unconfirmed",
path: "/bookings/unconfirmed",
"data-testid": "unconfirmed",
},
{
name: "recurring",
path: "/bookings/recurring",
"data-testid": "recurring",
},
{
name: "past",
path: "/bookings/past",
"data-testid": "past",
},
{
name: "cancelled",
path: "/bookings/cancelled",
"data-testid": "cancelled",
},
];
return baseTabConfigs.map((tabConfig) => ({
name: tabConfig.name,
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
"data-testid": tabConfig["data-testid"],
}));
}, [searchParams?.toString()]);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
@@ -234,7 +234,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventTypeId: eventType.id,
team: eventType.team,
owner: eventType.users[0] ?? null,
orgSlug: currentOrgDomain,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
profile,
eventType,
@@ -315,17 +315,20 @@ export default function Success(props: PageProps) {
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host,
attendee,
interpolation: { escapeValue: false },
});
}
if (isAttendee) {
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host,
attendee,
interpolation: { escapeValue: false },
});
}
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host,
attendee,
interpolation: { escapeValue: false },
});
}
return t(`emailed_host_and_attendee${titleSuffix}`);
@@ -546,7 +549,7 @@ export default function Success(props: PageProps) {
</h4>
)}
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
{(isCancelled || reschedule) && cancellationReason && (
<>
<div className="font-medium">
@@ -653,7 +653,7 @@ export const InfiniteEventTypeList = ({
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
className="w-full rounded-t-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
@@ -750,7 +750,7 @@ export const InfiniteEventTypeList = ({
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
className="w-full rounded-t-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
@@ -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">
@@ -3,22 +3,60 @@
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
type MonthOption = {
value: string;
label: string;
startDate: string;
endDate: string;
};
// returns the last 12 months starting from May 2025 (when credits were introduced)
const getMonthOptions = (): MonthOption[] => {
const options: MonthOption[] = [];
const minDate = dayjs.utc("2025-05-01");
let date = dayjs.utc();
let count = 0;
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
const startDate = date.startOf("month");
const endDate = date.endOf("month");
options.push({
value: date.format("MMMM YYYY"),
label: date.format("MMMM YYYY"),
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
date = date.subtract(1, "month");
count++;
}
return options;
};
export default function BillingCredits() {
const { t } = useLocale();
const router = useRouter();
const monthOptions = useMemo(() => getMonthOptions(), []);
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
const {
register,
@@ -28,8 +66,8 @@ export default function BillingCredits() {
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
const params = useParamsWithFallback();
const teamId = params.id ? Number(params.id) : undefined;
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
@@ -43,6 +81,27 @@ export default function BillingCredits() {
},
});
const handleDownload = async () => {
setIsDownloading(true);
try {
const result = await utils.viewer.credits.downloadExpenseLog.fetch({
teamId,
startDate: selectedMonth.startDate,
endDate: selectedMonth.endDate,
});
if (result?.csvData) {
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
downloadAsCsv(result.csvData, filename);
} else {
showToast(t("error_downloading_expense_log"), "error");
}
} catch (error) {
showToast(t("error_downloading_expense_log"), "error");
} finally {
setIsDownloading(false);
}
};
if (!IS_SMS_CREDITS_ENABLED) {
return null;
}
@@ -112,8 +171,8 @@ export default function BillingCredits() {
<hr className="border-subtle mb-3 mt-3" />
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex">
<div className="mr-auto">
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div>
<div className="-mb-1 mr-auto">
<Label>{t("buy_additional_credits")}</Label>
<div className="flex flex-col">
<TextField
required
@@ -132,12 +191,32 @@ export default function BillingCredits() {
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
</div>
</div>
<div className="mb-1 mt-auto">
<div className="mt-auto">
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
{t("buy_credits")}
</Button>
</div>
</form>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<div className="flex">
<div className="mr-auto ">
<Label className="mb-4">{t("download_expense_log")}</Label>
<div className="mt-2 flex flex-col">
<Select
options={monthOptions}
value={selectedMonth}
onChange={(option) => option && setSelectedMonth(option)}
/>
</div>
</div>
<div className="mt-auto">
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
{t("download")}
</Button>
</div>
</div>
</div>
</div>
</div>
@@ -45,7 +45,7 @@ export function BillingCreditsSkeleton() {
<div className="flex">
<div className="mr-auto">
<div>
<SkeletonText className="mb-2 mt-1 h-5 w-[268px]" /> {/* Buy credits label */}
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
</div>
<div>
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
@@ -56,6 +56,24 @@ export function BillingCreditsSkeleton() {
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
</div>
</div>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
{/* Download expense log */}
<div className="flex">
<div className="mr-auto mt-1">
<div>
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
</div>
<div>
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
</div>
</div>
<div className="mt-auto ">
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
</div>
</div>
</div>
</div>
</SkeletonContainer>
@@ -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")} />
@@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Badge } from "@calcom/ui/components/badge";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { TextField } from "@calcom/ui/components/form";
import { Alert } from "@calcom/ui/components/alert";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
return <AddNewTeamMembersForm />;
};
const useCheckout = () => {
const useOrgCreation = () => {
const { t } = useLocale();
const session = useSession();
const utils = trpc.useUtils();
const [serverErrorMessage, setServerErrorMessage] = useState("");
const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { reset } = useOnboardingStore();
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
onSuccess: (data) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
@@ -43,17 +48,34 @@ const useCheckout = () => {
},
});
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
onSuccess: async (data) => {
if (data.organization) {
// Invalidate the organizations query to ensure fresh data on the next page
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
reset();
window.location.href = `${window.location.origin}/settings/organizations/profile`;
}
},
onError: (error) => {
setServerErrorMessage(t(error.message));
},
});
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
return {
mutation,
mutate: mutation.mutate,
isPending: mutation.isPending,
mutation: mutationToUse,
mutate: mutationToUse.mutate,
isPending: mutationToUse.isPending,
errorMessage: serverErrorMessage,
};
};
export const AddNewTeamMembersForm = () => {
const { t } = useLocale();
const { useOnboardingStore } = useOnboarding();
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const {
addInvitedMember,
removeInvitedMember,
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
bio,
onboardingId,
} = useOnboardingStore();
const checkout = useCheckout();
const orgCreation = useOrgCreation();
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
return (
<>
{checkout.errorMessage && (
{orgCreation.errorMessage && (
<div className="mb-4">
<Alert severity="error" message={checkout.errorMessage} />
<Alert severity="error" message={orgCreation.errorMessage} />
</div>
)}
<div className="space-y-6">
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
</ul>
)}
</div>
<div className="mt-3 mt-6 flex items-center justify-end">
<Button
onClick={() => {
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
});
return;
}
checkout.mutation.mutate({
orgCreation.mutation.mutate({
logo,
bio,
teams,
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
onboardingId,
});
}}
loading={checkout.isPending}>
{t("checkout")}
loading={orgCreation.isPending}>
{isBillingEnabled ? t("checkout") : t("create")}
</Button>
</div>
</>
+16 -18
View File
@@ -691,25 +691,23 @@ export default function Signup({
/>
</div>
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
{FEATURES.map((feature) => (
<>
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="text-emphasis items-center">
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
</div>
<div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
{FEATURES.map((feature, index) => (
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="text-emphasis items-center">
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
</div>
</>
<div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
</div>
))}
</div>
</div>
+7 -1
View File
@@ -73,9 +73,11 @@ export interface DailyCustomTrayButton {
export const CalAiTranscribe = ({
showRecordingButton,
enableAutomaticTranscription,
showTranscriptionButton,
}: {
showRecordingButton: boolean;
enableAutomaticTranscription: boolean;
showTranscriptionButton: boolean;
}) => {
const daily = useDaily();
const { t } = useLocale();
@@ -101,7 +103,11 @@ export const CalAiTranscribe = ({
recording,
}
: {}),
transcription,
...(showTranscriptionButton
? {
transcription,
}
: {}),
});
};
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
overrideName,
showRecordingButton,
enableAutomaticTranscription,
showTranscriptionButton,
rediectAttendeeToOnExit,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
@@ -80,12 +81,16 @@ export default function JoinCall(props: PageProps) {
},
}
: {}),
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
...(showTranscriptionButton
? {
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
}
: {}),
},
}),
});
@@ -114,6 +119,7 @@ export default function JoinCall(props: PageProps) {
<CalAiTranscribe
showRecordingButton={showRecordingButton}
enableAutomaticTranscription={enableAutomaticTranscription}
showTranscriptionButton={showTranscriptionButton}
/>
</div>
<div style={{ zIndex: 2, position: "relative" }}>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.2",
"version": "5.4.13",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -194,7 +194,7 @@
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^5.8.3"
"typescript": "^5.7.2"
},
"nextBundleAnalysis": {
"budget": 358400,
+5 -4
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,8 +120,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
if (!orgId) {
orgId = await getOrgIdFromMemberOrTeamId({
memberId: parsedUserId,
orgId = await getPublishedOrgIdFromMemberOrTeamId({
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
teamId: parsedTeamId,
});
}
@@ -137,7 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
}
const price = await twilio.getPriceForSMS(smsSid);
const { price, numSegments } = await twilio.getMessageInfo(smsSid);
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
@@ -147,6 +147,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
userId: parsedUserId,
smsSid,
bookingUid: parsedBookingUid,
smsSegments: numSegments ?? undefined,
});
if (chargedUserOrTeamId) {
+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">
+5 -5
View File
@@ -7,7 +7,7 @@ import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import {
bookTimeSlot,
createNewEventType,
createNewUserEventType,
gotoBookingPage,
gotoFirstEventType,
saveEventType,
@@ -55,7 +55,7 @@ test.describe("Event Types tests", () => {
test("can add new event type", async ({ page }) => {
const nonce = randomString(3);
const eventTitle = `hello ${nonce}`;
await createNewEventType(page, { eventTitle });
await createNewUserEventType(page, { eventTitle });
await page.goto("/event-types");
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
});
@@ -63,7 +63,7 @@ test.describe("Event Types tests", () => {
test("new event type appears first in the list", async ({ page }) => {
const nonce = randomString(3);
const eventTitle = `hello ${nonce}`;
await createNewEventType(page, { eventTitle });
await createNewUserEventType(page, { eventTitle });
await page.goto("/event-types");
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
const firstEventTitle = await firstEvent.getAttribute("title");
@@ -73,7 +73,7 @@ test.describe("Event Types tests", () => {
test("enabling recurring event comes with default options", async ({ page }) => {
const nonce = randomString(3);
const eventTitle = `my recurring event ${nonce}`;
await createNewEventType(page, { eventTitle });
await createNewUserEventType(page, { eventTitle });
// fix the race condition
await page.waitForSelector('[data-testid="event-title"]');
@@ -393,7 +393,7 @@ test.describe("Event Types tests", () => {
}) => {
const nonce = randomString(3);
const eventTitle = `Conflict event ${nonce}`;
await createNewEventType(page, { eventTitle });
await createNewUserEventType(page, { eventTitle });
await page.goto("/event-types");
await page.click(`text=${eventTitle}`);
@@ -0,0 +1,45 @@
import { expect } from "@playwright/test";
import { loginUser } from "playwright/fixtures/regularBookings";
import { MembershipRole } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("AI Translation - Event Type", () => {
test("Toggle should not be clickable for free plan members", async ({ page, users, bookingPage }) => {
await loginUser(users);
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
});
test("Toggle should not be clickable for team plan members", async ({ page, users, bookingPage }) => {
const user = await users.create(
{ name: "testuser" },
{
hasTeam: true,
teamRole: MembershipRole.MEMBER,
}
);
await user.apiLogin();
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
});
test("Toggle should be clickable for org members", async ({ page, orgs, users, bookingPage }) => {
const org = await orgs.create({
name: "acme",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
await user.apiLogin();
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(false);
});
});
@@ -198,5 +198,19 @@ export function createBookingPageFixture(page: Page) {
checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
},
getAITranslationToggleDisabled: async () => {
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
timeout: 5000,
state: "attached",
});
const toggle = page.getByTestId("ai_translation_toggle");
return (await toggle.getAttribute("disabled")) !== null;
},
toggleAITranslation: async () => {
await page.getByTestId("ai_translation_toggle").click();
},
updateEventTypeDescription: async (description: string) => {
await page.getByTestId("editor-input").fill(description);
},
};
}
+1 -1
View File
@@ -395,7 +395,7 @@ export const createUsersFixture = (
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
+31 -4
View File
@@ -7,12 +7,14 @@ import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports
import type { Messages } from "mailhog";
import { totp } from "otplib";
import { v4 as uuid } from "uuid";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { createEmailsFixture } from "../fixtures/emails";
import type { CreateUsersFixture } from "../fixtures/users";
import type { Fixtures } from "./fixtures";
type Request = IncomingMessage & { body?: unknown };
@@ -177,10 +179,12 @@ export async function expectSlotNotAllowedToBook(page: Page) {
await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible();
}
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
export const createNewUserEventType = async (page: Page, args: { eventTitle: string; username?: string }) => {
await page.click("[data-testid=new-event-type]");
const eventTitle = args.eventTitle;
await page.fill("[name=title]", eventTitle);
if (args.username) {
await page.getByRole("button", { name: args.username }).click();
}
await page.fill("[name=title]", args.eventTitle);
await page.fill("[name=length]", "10");
await page.click("[type=submit]");
@@ -216,7 +220,7 @@ export async function setupManagedEvent({
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
const eventTitle = args.eventTitle;
await createNewEventType(page, { eventTitle });
await createNewUserEventType(page, { eventTitle });
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
@@ -563,3 +567,26 @@ export async function expectPageToBeNotFound({ page, url }: { page: Page; url: s
await page.goto(`${url}`);
await expect(page.getByTestId(`404-page`)).toBeVisible();
}
export async function setupOrgMember(users: CreateUsersFixture) {
const orgRequestedSlug = `example-${uuid()}`;
const orgMember = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
isOrgVerified: true,
isDnsSetup: true,
orgRequestedSlug,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team: org } = await orgMember.getOrgMembership();
const { team } = await orgMember.getFirstTeamMembership();
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
const userEvent = orgMember.eventTypes[0];
await orgMember.apiLogin();
return { orgMember, org, team, teamEvent, userEvent };
}
@@ -12,7 +12,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import { test } from "./lib/fixtures";
import {
createHttpServer,
createNewEventType,
createNewUserEventType,
selectFirstAvailableTimeSlotNextMonth,
submitAndWaitForResponse,
} from "./lib/testUtils";
@@ -752,7 +752,7 @@ test.describe("Text area min and max characters text", () => {
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await createNewEventType(page, { eventTitle });
await createNewUserEventType(page, { eventTitle });
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(3);
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(3);
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
@@ -1,36 +1,9 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { doOnOrgDomain } from "playwright/lib/testUtils";
import { v4 as uuid } from "uuid";
import { doOnOrgDomain, setupOrgMember } from "playwright/lib/testUtils";
import { SchedulingType } from "@calcom/prisma/enums";
import type { CreateUsersFixture } from "../fixtures/users";
import { test } from "../lib/fixtures";
async function setupOrgMember(users: CreateUsersFixture) {
const orgRequestedSlug = `example-${uuid()}`;
const orgMember = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
isOrgVerified: true,
isDnsSetup: true,
orgRequestedSlug,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team: org } = await orgMember.getOrgMembership();
const { team } = await orgMember.getFirstTeamMembership();
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
const userEvent = orgMember.eventTypes[0];
await orgMember.apiLogin();
return { orgMember, org, team, teamEvent, userEvent };
}
type TestContext = Awaited<ReturnType<typeof setupOrgMember>>;
interface ToggleSeoSwitchParams {
+41 -9
View File
@@ -48,7 +48,24 @@
"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",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
@@ -265,6 +282,8 @@
"guests": "Guests",
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation":"Confirmation",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username",
"username": "Username",
"is_still_available": "is still available.",
@@ -1708,6 +1727,8 @@
"show_available_seats_count": "Show the number of available seats",
"how_booking_questions_as_variables": "How to use booking questions as variables?",
"format": "Format",
"questions": "Questions",
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
"uppercase_for_letters": "Use uppercase for all letters",
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
"platform_members": "Platform members",
@@ -2833,7 +2854,7 @@
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
"salesforce_change_record_owner_on_booking": "Change record owner on booking",
"mass_assign_attributes": "Mass assign attributes",
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
@@ -2886,6 +2907,7 @@
"yes_reassign": "Yes, reassign",
"filter_by_attributes": "Filter by attributes",
"reassign_reason": "Reason for reassigning",
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
"reassigned_by": "Reassigned by",
"row": "row",
"rows": "rows",
@@ -2931,7 +2953,7 @@
"routing_form_insights_assignment_reason": "Assignment Reason",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_add_attendees_as":"Add new attendees as",
"salesforce_add_attendees_as": "Add new attendees as",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
@@ -2994,7 +3016,7 @@
"shortfall": "Shortfall",
"routing_form": "Routing Form",
"go_back_and_save": "Go back and save",
"save_changes":"Save changes",
"save_changes": "Save changes",
"leave_without_saving": "Leave without saving",
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
"something_unexpected_occurred": "Something unexpected occurred",
@@ -3029,9 +3051,9 @@
"authorize": "Authorize",
"next": "Next",
"hide": "Hide",
"show":"Show",
"utm_params":"UTM parameters",
"no_utm_params":"No UTM parameters available.",
"show": "Show",
"utm_params": "UTM parameters",
"no_utm_params": "No UTM parameters available.",
"asc": "Asc",
"desc": "Desc",
"verify_email_change": "Verify email change",
@@ -3101,7 +3123,7 @@
"user_has_no_team_yet": "You don't have a team yet",
"no_team_members": "You don't have team members yet",
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
"segment": "Segment",
@@ -3179,8 +3201,8 @@
"resend": "Resend",
"verification_email_sent": "Verification email sent",
"select_all_members": "Select all members",
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.",
"form_settings":"Form Settings",
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
"form_settings": "Form Settings",
"remove_whitelist_status": "Remove whitelist status",
"whitelist_user_workflows": "Whitelist user workflows",
"user_workflows_whitelisted": "User workflows whitelisted",
@@ -3238,6 +3260,10 @@
"requires_credits": "Requires credits",
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
"available_credits": "Available credits",
"choose_restriction_schedule": "Add restriction schedule",
"choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.",
"use_booker_timezone": "Use booker's timezone",
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
"round_robin_settings_description": "Customize the default round robin settings for this team",
@@ -3246,10 +3272,16 @@
"meeting_start_time": "Meeting Start Time",
"members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"download_expense_log": "Download Expense Log",
"error_downloading_expense_log": "Error downloading expense log",
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
"duration_min_error": "Duration must be at least {{min}} minute(s)",
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+29 -29
View File
@@ -161,7 +161,7 @@
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
"notes": "Jegyzetek",
"manage_my_bookings": "Foglalásaim kezelése",
"need_to_make_a_change": "Változtatnál valamit?",
"need_to_make_a_change": "Változtatna valamin?",
"new_event_scheduled": "Új eseményt foglaltak.",
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
"invitee_email": "Meghívó e-mail",
@@ -240,7 +240,7 @@
"copy_public_page_link": "Nyilvános oldal linkjének másolása",
"copy_referral_link": "Ajánlói link másolása",
"sign_out": "Kijelentkezés",
"add_another": "Adj hozzá egy másikat",
"add_another": "Másvalaki hozzáadása",
"install_another": "Telepítsen másikat",
"until": "amíg",
"powered_by": "működteti",
@@ -354,10 +354,10 @@
"connect_caldav": "Csatlakozás a CalDav szerverhez",
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
"connect": "Csatlakozás",
"try_for_free": "Próbáld ki ingyen",
"try_for_free": "Próbálja ki ingyen",
"create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével",
"who": "Ki",
"what": "Mit",
"what": "Mi",
"when": "Mikor",
"where": "Hol",
"add_to_calendar": "Hozzáadás naptárhoz",
@@ -373,32 +373,32 @@
"round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.",
"needs_to_be_confirmed_or_rejected": "Foglalását még meg kell erősíteni vagy el kell utasítani.",
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozót még meg kell erősíteni vagy el kell utasítani.",
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges a foglalásod véglegesítéséhez.",
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még mindig vissza kell igazolnia vagy elutasítania a foglalást az ismétlődő találkozóra.",
"needs_to_be_confirmed_or_rejected": "Foglalását még megerősítésre vagy lemondásra vár.",
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozó még megerősítésre vagy lemondásra vár.",
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges foglalása véglegesítéséhez.",
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még vissza kell igazolnia vagy el kell utasítania a foglalást az ismétlődő találkozóra.",
"meeting_is_scheduled": "Találkozó sikeresen lefoglalva",
"meeting_is_scheduled_recurring": "Az ismétlődő időpontod le lett foglalva",
"booking_submitted": "A foglalásod be lett küldve",
"booking_submitted_recurring": "Ismétlődő találkozóját elküldtük",
"booking_confirmed": "A foglalásod vissza lett igazolva",
"meeting_is_scheduled_recurring": "Az ismétlődő időpontja le lett foglalva",
"booking_submitted": "Foglalását elküldte",
"booking_submitted_recurring": "Ismétlődő találkozóját elküldte",
"booking_confirmed": "Foglalását visszaigazolták",
"booking_confirmed_recurring": "Ismétlődő találkozóját megerősítették",
"warning_recurring_event_payment": "A fizetések még nem támogatottak ismétlődő események esetén",
"warning_payment_recurring_event": "Az ismétlődő eseményeket a fizetés még nincs támogatva",
"enter_new_password": "Adja meg a fiókjához kívánt új jelszót.",
"reset_password": "Jelszó visszaállítása",
"change_your_password": "Változtasd meg a jelszavadat",
"show_password": "Mutasd a jelszót",
"change_your_password": "Változtassa meg a jelszavát",
"show_password": "Jelszó megjelenítése",
"hide_password": "Jelszó elrejtése",
"try_again": "Próbáld újra",
"try_again": "Próbálja újra",
"request_is_expired": "Ez a kérelem lejárt.",
"reset_instructions": "Adja meg a fiókjához társított e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
"reset_instructions": "Adja meg a fiókjához tartozó e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
"request_is_expired_instructions": "Ez a kérelem lejárt. Menjen vissza, és adja meg a fiókjához tartozó e-mail-címet, és küldünk egy újabb linket a jelszó visszaállításához.",
"whoops": "Hoppá",
"login": "Bejelentkezés",
"success": "Siker",
"failed": "Nem sikerült",
"password_has_been_reset_login": "A jelszavad vissza lett állítva. Most már bejelentkezhet az újonnan létrehozott jelszavával.",
"password_has_been_reset_login": "Jelszavad visszaállítottuk. Most már bejelentkezhetsz az újonnan létrehozott jelszavaddal.",
"layout": "Elrendezés",
"bookerlayout_default_title": "Alapértelmezett nézet",
"bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.",
@@ -641,41 +641,41 @@
"additional_notes": "Egyéb megjegyzés",
"booking_fail": "Találkozó nem foglalható.",
"reschedule_fail": "Nem sikerült időpontot módisítani.",
"share_additional_notes": "Kérlek ossz meg velünk bármit, ami segíthet felkészülni a találkozóra.",
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét a következővel: {{profileName}}",
"booking_reschedule_confirmation": "Módosítsd {{eventTypeTitle}} időpontod nála: {{profileName}}",
"share_additional_notes": "Kérjük osszon meg velünk bármit, ami segíthet felkészülni a találkozóra.",
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét vele: {{profileName}}",
"booking_reschedule_confirmation": "Módosítsa {{eventTypeTitle}} időpontját nála: {{profileName}}",
"in_person_meeting": "Személyes találkozó",
"in_person": "Személyesen (Szervező címén)",
"link_meeting": "Találkozó összekapcsolása",
"phone_number": "Telefonszám",
"attendee_phone_number": "A résztvevő telefonszáma",
"attendee_phone_number": "Résztvevő telefonszáma",
"organizer_phone_number": "Szervező telefonszáma",
"enter_phone_number": "Adja meg a telefonszámot",
"reschedule": "átütmezés",
"reschedule_this": "Inkább időpontmódosítás",
"book_a_team_member": "Foglaljon helyette egy csapattagot",
"or": "VAGY",
"book_a_team_member": "Foglaljon inkáb egy másik csapattagnál",
"or": "vagy",
"go_back": "Vissza",
"email_or_username": "E-mail cím vagy felhasználónév",
"send_invite_email": "Küldj meghívó e-mailt",
"send_invite_email": "Meghívó e-mail küldése",
"role": "Szerepkör",
"edit_role": "Szerepkör szerkesztése",
"edit_team": "Csapat szerkesztése",
"editing_user": "Felhasználó szerkesztése",
"editing_org": "Szervezet szerkesztése",
"edit_or_manage_webhooks": "Ha szeretnéd szerkeszteni vagy kezelni a webhookjaidat, látogass el a <0>webhook beállításokhoz</0>",
"reject": "Elutasít",
"reject": "Elutasítás",
"reject_all": "Az összes elutasítása",
"accept": "Elfogad",
"leave": "Elhagyás",
"accept": "Elfogadás",
"leave": "Kilépés",
"profile": "Profil",
"my_team_url": "Csapatom URL-je",
"my_teams": "A csapataim",
"team_name": "Csapat neve",
"your_team_name": "A csapatod neve",
"team_updated_successfully": "A csapat sikeresen frissítve",
"your_team_updated_successfully": "Csapatát sikeresen frissítette.",
"your_org_updated_successfully": "Szervezete sikeresen frissítve.",
"your_team_updated_successfully": "Csapatod sikeresen frissítetted.",
"your_org_updated_successfully": "Szervezet sikeresen frissítve.",
"error_copying_to_clipboard": "Hiba a vágólapra másolás során",
"about": "Rólunk",
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
+19 -19
View File
@@ -66,18 +66,18 @@
"rejection_reason_title": "Odbij zahtev za rezervaciju?",
"rejection_reason_description": "Da li ste sigurni da želite da odbijete rezervaciju? Obavestićemo osobu koja je pokušala da rezerviše. Možete navesti razlog ispod.",
"rejection_confirmation": "Odbij rezervaciju",
"manage_this_event": "Upravljaj ovim dogadjajem",
"manage_this_event": "Upravljaj ovim događajem",
"invite_team_member": "Pozovite člana tima",
"invite_team_individual_segment": "Pozovite jednu osobu",
"invite_team_bulk_segment": "Pozovite grupno",
"invite_team_notifcation_badge": "Poz.",
"your_event_has_been_scheduled": "Vaš dogadjaj je odložen",
"your_event_has_been_scheduled": "Vaš događaj je zakazan",
"your_event_has_been_scheduled_recurring": "Vaš ponavljajući događaj je zakazan",
"accept_our_license": "Prihvatite našu dozvolu tako što ćete promeniti .env varijablu <1>NEXT_PUBLIC_LICENSE_CONSENT</1> u '{{agree}}'.",
"remove_banner_instructions": "Da uklonite baner, molim vas da otvorite vaš .env file i promenite <1>NEXT_PUBLIC_LICENSE_CONSENT</1> varijablu u '{{agree}}'.",
"error_message": "Poruka o grešci je bila: '{{errorMessage}}'",
"refund_failed_subject": "Povraćaj novca neuspešan: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Povraćaj novca za dogadjaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
"refund_failed": "Povraćaj novca za događaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
"a_refund_failed": "Povraćaj novca neuspešan",
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
@@ -115,23 +115,23 @@
"open_env": "Otvorite .env i pristanite na našu Licencu",
"env_changed": "Promenio/la sam moj .env",
"accept_license": "Prihvati Licencu",
"still_waiting_for_approval": "Dogadjaj još čeka na odobrenje",
"event_is_still_waiting": "Zahtev na dogadjaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
"still_waiting_for_approval": "Događaj još čeka na odobrenje",
"event_is_still_waiting": "Zahtev na događaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
"no_result_found_for": "Nema rezultata za \"{{searchTerm}}\"",
"no_more_results": "Nema više rezultata",
"no_results": "Nema rezultata",
"load_more_results": "Učitaj više rezultata",
"integration_meeting_id": "{{integrationName}} ID sastanka: {{meetingId}}",
"confirmed_event_type_subject": "Potvrdjeno: {{eventType}} sa {{name}} datuma {{date}}",
"new_event_request": "Novi zahtev na dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
"new_event_request": "Novi zahtev na događaj: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "Potvrdite ili odbijte zahtev",
"check_bookings_page_to_confirm_or_reject": "Proverite stranicu rezervacija da potvrdite ili odbijete rezervaciju.",
"check_in_assistant": "Asistent za prijavu",
"check_in_assistant_description": "Obavlja odlazni poziv da proveri da li zakazani termin odgovara ili želite da ga pomerite.",
"create_your_own_prompt": "Kreirajte sopstveni upit i koristite ga za odlazne pozive.",
"event_awaiting_approval": "Dogadjaj čeka vaše odobrenje",
"event_awaiting_approval": "Događaj čeka vaše odobrenje",
"event_awaiting_approval_recurring": "Ponavljajući događaj čeka na vaše odobrenje",
"someone_requested_an_event": "Neko zahteva da rezerviše dogadjaj na vašem kalendaru.",
"someone_requested_an_event": "Neko zahteva da rezerviše događaj na vašem kalendaru.",
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
"password_reset_email_sent": "Ako ovaj imejl postoji našem sistemu, trebalo bi da primite imejl za resetovanje.",
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
@@ -145,7 +145,7 @@
"your_meeting_has_been_booked": "Sastanak je zakazan",
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
"event_type_has_been_updated": "Vaš {{title}} je ažuriran.",
"event_has_been_rescheduled": "Ažurirano - Vaš dogadjaj je odložen",
"event_has_been_rescheduled": "Ažurirano - Vaš događaj je odložen",
"event_has_been_updated": "Događaj je ažuriran",
"your_event_has_been_rescheduled": "Vaš događaj je ponovo zakazan",
"request_reschedule_subtitle": "{{organizer}} je otkazao/la rezervaciju i tražio/la je da odaberete drugo vreme.",
@@ -157,17 +157,17 @@
"ics_event_title": "{{eventType}} sa {{name}}",
"please_book_a_time_sometime_later": "Izgleda da su svi naši stručnjaci trenutno zauzeti. Molimo vas da zakažete poziv u vreme koje vam najviše odgovara i bićemo spremni da vam pomognemo.",
"schedule_instead": "Zakažite umesto toga",
"new_event_subject": "Novi dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
"new_event_subject": "Novi događaj: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Pridruži se putem {{entryPoint}}",
"notes": "Beleške",
"manage_my_bookings": "Upravljaj svojim rezervacijama",
"need_to_make_a_change": "Trebate napraviti promenu?",
"new_event_scheduled": "Novi dogadjaj je zakazan.",
"new_event_scheduled": "Novi događaj je zakazan.",
"new_event_scheduled_recurring": "Novi ponavljajući događaj je zakazan.",
"invitee_email": "E-pošta od pozvane osobe",
"invitee_timezone": "Vremenska zona pozvane osobe",
"time_left": "Preostalo vreme",
"event_type": "Tip Dogadjaja",
"event_type": "Tip Događaja",
"duplicate_event_type": "Duplirani tip događaja",
"enter_meeting": "Pridruži se sastanku",
"video_call_provider": "Provajder video poziva",
@@ -205,7 +205,7 @@
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
"hey_there": "Zdravo",
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je dogadjaj zakazan ili otkazan.",
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je događaj zakazan ili otkazan.",
"confirm_delete_webhook": "Da, obriši webhook",
"edit_webhook": "Uredi Webhook",
"delete_webhook": "Obriši Webhook",
@@ -229,7 +229,7 @@
"add_to_homescreen": "Dodaj ovu aplikaciju na telefon za brži pristup i poboljšano korisničko iskustvo.",
"upcoming": "Predstojeći",
"recurring": "Ponavljajući",
"past": "Prošli dogadjaji",
"past": "Prošli događaji",
"choose_a_file": "Odaberi datoteku...",
"upload_image": "Otpremi sliku",
"upload_target": "Prikači {{target}}",
@@ -343,7 +343,7 @@
"connect_your_calendar": "Povežite vaš kalendar",
"connect_your_video_app": "Povežite svoje video aplikacije",
"connect_your_video_app_instructions": "Povežite svoje video aplikacije da biste ih koristili u vašim tipovima događaja.",
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove dogadjaje kad se zakažu.",
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove događaje kad se zakažu.",
"set_up_later": "Postavi kasnije",
"current_time": "Trenutno vreme",
"details": "Detalji",
@@ -422,7 +422,7 @@
"error_end_time_before_start_time": "Krajnje vreme ne može biti pre početnog vremena",
"error_end_time_next_day": "Krajnje vreme ne može biti veće od 24 sata",
"back_to_bookings": "Nazad na rezervacije",
"free_to_pick_another_event_type": "Slobodno izaberite drugi dogadjaj u bilo kom trenutku.",
"free_to_pick_another_event_type": "Slobodno izaberite drugi događaj u bilo kom trenutku.",
"cancelled": "Otkazano",
"no_booking": "Nema rezervacije",
"awaiting_host": "Čeka se domaćin",
@@ -450,7 +450,7 @@
"event_between_users": "{{eventName}} sa {{host}} i {{attendeeName}}",
"bookings": "Rezervacije",
"booking_not_found": "Rezervacija nije pronađena",
"bookings_description": "Vidite nadolazeće i prošle dogadjaje rezervisane preko linkova vaših tipova događaja.",
"bookings_description": "Vidite nadolazeće i prošle događaje rezervisane preko linkova vaših tipova događaja.",
"upcoming_bookings": "Čim neko rezerviše termin sa vama to će se pokazati ovde.",
"recurring_bookings": "Čim neko zakaže ponavljajući sastanak sa vama, to će se pokazati ovde.",
"past_bookings": "Vaše prethodne rezervacije će se pojaviti ovde.",
@@ -522,7 +522,7 @@
"booking_paid": "Rezervacija je plaćena",
"ooo_created": "Kreirano odsustvo iz kancelarije",
"booking_no_show_updated": "Ažurirano odsustvo sa rezervacije",
"event_triggers": "Okidači Dogadjaja",
"event_triggers": "Okidači Događaja",
"subscriber_url": "URL pretplatnika",
"create_new_webhook": "Napravite novi webhook",
"webhooks": "Webhooks",
@@ -737,7 +737,7 @@
"remove_member": "Ukloni člana",
"manage_your_team": "Upravljaj svojim timom",
"no_teams": "Još uvek nemate nijedan tim.",
"no_teams_description": "Timovi služe za rezervacije dogadjaja koje delite sa vašim kolegama.",
"no_teams_description": "Timovi služe za rezervacije događaja koje delite sa vašim kolegama.",
"submit": "Podnesi",
"confirm_remove_attribute": "Da, ukloni atribut",
"remove_attribute": "Ukloni atribut",
@@ -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");
@@ -0,0 +1,479 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import {
createBookingScenario,
Timezones,
TestData,
type ScenarioData,
} from "../../utils/bookingScenario/bookingScenario";
import { describe, test, vi } from "vitest";
import type { z } from "zod";
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { expect } from "./expects";
import { setupAndTeardown } from "./setupAndTeardown";
// Mock the FeaturesRepository to enable restriction-schedule feature
vi.mock("@calcom/features/flags/features.repository", () => ({
FeaturesRepository: vi.fn().mockImplementation(() => ({
checkIfTeamHasFeature: vi.fn().mockResolvedValue(true),
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
getAllFeatures: vi.fn().mockResolvedValue([]),
getFeatureFlagMap: vi.fn().mockResolvedValue({}),
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
})),
}));
type ScheduleScenario = {
eventTypes: ScenarioData["eventTypes"];
users: ScenarioData["users"];
apps: ScenarioData["apps"];
selectedSlots?: ScenarioData["selectedSlots"];
};
const getTestScheduleInput = ({
yesterdayDateString,
plus5DateString,
}: {
yesterdayDateString: string;
plus5DateString: string;
}): z.infer<typeof getScheduleSchema> => ({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${yesterdayDateString}T18:30:00.000Z`,
endTime: `${plus5DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
orgSlug: null,
});
const getBaseScenarioData = (): ScheduleScenario => ({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
teamId: 1,
hosts: [{ userId: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
teams: [
{
membership: { role: "ADMIN", accepted: true },
team: { id: 1, name: "Test Team", slug: "test-team" },
},
],
},
],
apps: [],
});
// Helper function to set up team for the tests
async function setupTeamAndFeatures() {
// Create the team (needed for event type configuration)
await prismock.team.create({
data: {
id: 1,
name: "Test Team",
slug: "test-team",
},
});
}
describe("getSchedule", () => {
setupAndTeardown();
describe("Restriction Schedule", () => {
test("should respect date override rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
await setupTeamAndFeatures();
vi.setSystemTime("2025-06-01T23:30:00Z");
const plus2DateString = "2025-06-02"; // Date with override
const plus5DateString = "2025-06-05";
const scenarioData: ScheduleScenario = {
...getBaseScenarioData(),
eventTypes: [
{
id: 1,
length: 60,
teamId: 1,
restrictionScheduleId: 50,
useBookerTimezone: false,
hosts: [{ userId: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "Test Schedule",
timeZone: "Europe/London",
availability: [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:00:00.000Z"),
endTime: new Date("1970-01-01T17:00:00.000Z"),
date: null,
},
],
},
],
teams: [
{
membership: { role: "ADMIN", accepted: true },
team: { id: 1, name: "Test Team", slug: "test-team" },
},
],
},
],
};
scenarioData.users[0].schedules.push({
id: 50,
name: "Restriction Schedule - Only Date Override",
timeZone: "Asia/Dubai",
availability: [
{
date: "2025-06-02T00:00:00.000Z",
days: [],
startTime: new Date("1970-01-01T10:00:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
},
],
});
await createBookingScenario(scenarioData);
const result = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: "2025-06-01T00:00:00.000Z",
endTime: `${plus5DateString}T23:59:59.999Z`,
timeZone: "Asia/Kolkata",
isTeamEvent: true,
orgSlug: null,
},
});
// Expected slots based on intersection of:
// - User availability: 9 AM - 5 PM London
// - Restriction: 10 AM - 6 PM Dubai
const expectedUtcSlots = [
"2025-06-02T08:00:00.000Z", // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
"2025-06-02T09:00:00.000Z", // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
"2025-06-02T10:00:00.000Z", // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
"2025-06-02T11:00:00.000Z", // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
"2025-06-02T12:00:00.000Z", // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
"2025-06-02T13:00:00.000Z", // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
"2025-06-02T14:00:00.000Z", // 3 PM London, 6 PM Dubai, 7:30 PM Kolkata
];
expect(result).toHaveTimeSlots(expectedUtcSlots, {
dateString: plus2DateString,
doExactMatch: false,
});
expect(result).toHaveDateDisabled({ dateString: "2025-06-03" });
});
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
await setupTeamAndFeatures();
vi.setSystemTime("2025-06-01T23:30:00Z");
const plus2DateString = "2025-06-02"; // Monday
const plus6DateString = "2025-06-06"; // Friday
const scenarioData: ScheduleScenario = {
...getBaseScenarioData(),
eventTypes: [
{
id: 1,
length: 60,
teamId: 1,
restrictionScheduleId: 50,
useBookerTimezone: false,
hosts: [{ userId: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "Test Schedule",
timeZone: "Europe/London",
availability: [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:00:00.000Z"),
endTime: new Date("1970-01-01T17:00:00.000Z"),
date: null,
},
],
},
],
teams: [
{
membership: { role: "ADMIN", accepted: true },
team: { id: 1, name: "Test Team", slug: "test-team" },
},
],
},
],
};
scenarioData.users[0].schedules.push({
id: 50,
name: "Test Schedule",
timeZone: "Asia/Dubai",
availability: [
{
days: [1, 2, 3, 4, 5],
startTime: new Date("1970-01-01T10:00:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
],
});
await createBookingScenario(scenarioData);
const result = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: "2025-06-01T00:00:00.000Z",
endTime: `${plus6DateString}T23:59:59.999Z`,
timeZone: "Asia/Kolkata",
isTeamEvent: true,
orgSlug: null,
},
});
// Expected slots based on intersection of:
// - User availability: 9 AM - 5 PM London
// - Restriction: 10 AM - 6 PM Dubai, weekdays only
const getExpectedSlotsForDate = (dateString: string) => [
`${dateString}T08:00:00.000Z`, // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
`${dateString}T09:00:00.000Z`, // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
`${dateString}T10:00:00.000Z`, // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
`${dateString}T11:00:00.000Z`, // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
`${dateString}T12:00:00.000Z`, // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
`${dateString}T13:00:00.000Z`, // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
];
// Verify weekday slots
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
dateString,
doExactMatch: false,
});
});
// Verify weekend is disabled
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
});
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=true)", async () => {
await setupTeamAndFeatures();
vi.setSystemTime("2025-06-01T23:30:00Z");
const plus2DateString = "2025-06-02"; // Monday
const plus6DateString = "2025-06-06"; // Friday
const scenarioData: ScheduleScenario = {
...getBaseScenarioData(),
eventTypes: [
{
id: 1,
length: 60,
teamId: 1,
restrictionScheduleId: 50,
useBookerTimezone: true,
hosts: [{ userId: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "Test Schedule",
timeZone: "Europe/London",
availability: [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:00:00.000Z"),
endTime: new Date("1970-01-01T17:00:00.000Z"),
date: null,
},
],
},
],
teams: [
{
membership: { role: "ADMIN", accepted: true },
team: { id: 1, name: "Test Team", slug: "test-team" },
},
],
},
],
};
scenarioData.users[0].schedules.push({
id: 50,
name: "Test Schedule",
timeZone: "Asia/Dubai",
availability: [
{
days: [1, 2, 3, 4, 5],
startTime: new Date("1970-01-01T10:00:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
],
});
await createBookingScenario(scenarioData);
const result = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: "2025-06-01T00:00:00.000Z",
endTime: `${plus6DateString}T23:59:59.999Z`,
timeZone: "Asia/Kolkata",
isTeamEvent: true,
orgSlug: null,
},
});
// Expected slots based on:
// - User availability: 9 AM - 5 PM London (1:30 PM - 9:30 PM Kolkata)
// - Restriction: 10 AM - 6 PM Kolkata
const getExpectedSlotsForDate = (dateString: string) => [
`${dateString}T08:00:00.000Z`, // 1:30 PM Kolkata, 9 AM London
`${dateString}T09:00:00.000Z`, // 2:30 PM Kolkata, 10 AM London
`${dateString}T10:00:00.000Z`, // 3:30 PM Kolkata, 11 AM London
`${dateString}T11:00:00.000Z`, // 4:30 PM Kolkata, 12 PM London
`${dateString}T12:00:00.000Z`, // 5:30 PM Kolkata, 1 PM London
];
// Verify weekday slots
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
dateString,
doExactMatch: false,
});
});
// Verify weekend is disabled
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
});
test("should return all slots when no restriction schedule is applied", async () => {
await setupTeamAndFeatures();
vi.setSystemTime("2025-06-01T23:30:00Z");
const plus2DateString = "2025-06-02"; // Monday
const plus6DateString = "2025-06-06"; // Friday
const scenarioData: ScheduleScenario = {
...getBaseScenarioData(),
eventTypes: [
{
id: 1,
length: 60,
teamId: 1,
useBookerTimezone: false,
hosts: [{ userId: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "Test Schedule",
timeZone: "Europe/London",
availability: [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:00:00.000Z"),
endTime: new Date("1970-01-01T17:00:00.000Z"),
date: null,
},
],
},
],
teams: [
{
membership: { role: "ADMIN", accepted: true },
team: { id: 1, name: "Test Team", slug: "test-team" },
},
],
},
],
};
await createBookingScenario(scenarioData);
const result = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: "2025-06-01T00:00:00.000Z",
endTime: `${plus6DateString}T23:59:59.999Z`,
timeZone: "Asia/Kolkata",
isTeamEvent: true,
orgSlug: null,
},
});
// Expected slots based on user's availability: 9 AM - 5 PM London
const getExpectedSlotsForDate = (dateString: string) => [
`${dateString}T08:00:00.000Z`, // 9 AM London, 1:30 PM Kolkata
`${dateString}T09:00:00.000Z`, // 10 AM London, 2:30 PM Kolkata
`${dateString}T10:00:00.000Z`, // 11 AM London, 3:30 PM Kolkata
`${dateString}T11:00:00.000Z`, // 12 PM London, 4:30 PM Kolkata
`${dateString}T12:00:00.000Z`, // 1 PM London, 5:30 PM Kolkata
`${dateString}T13:00:00.000Z`, // 2 PM London, 6:30 PM Kolkata
`${dateString}T14:00:00.000Z`, // 3 PM London, 7:30 PM Kolkata
`${dateString}T15:00:00.000Z`, // 4 PM London, 8:30 PM Kolkata
`${dateString}T16:00:00.000Z`, // 5 PM London, 9:30 PM Kolkata
];
// Verify weekday slots
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
dateString,
doExactMatch: false,
});
});
// Verify a late evening slot (8:30 PM Kolkata, 4 PM London) is present
const lateEveningSlot = `${plus2DateString}T15:00:00.000Z`;
expect(result.slots[plus2DateString].map((s) => s.time)).toContain(lateEveningSlot);
});
});
});
@@ -147,6 +147,8 @@ describe("handleChildrenEventTypes", () => {
userId: 4,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useBookerTimezone: false,
restrictionScheduleId: null,
allowReschedulingCancelledBookings: false,
},
});
@@ -203,6 +205,8 @@ describe("handleChildrenEventTypes", () => {
scheduleId: null,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
useBookerTimezone: false,
restrictionScheduleId: null,
hashedLink: {
deleteMany: {},
},
@@ -317,6 +321,8 @@ describe("handleChildrenEventTypes", () => {
workflows: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useBookerTimezone: false,
restrictionScheduleId: null,
allowReschedulingCancelledBookings: false,
},
});
@@ -372,6 +378,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: {
deleteMany: {},
},
useBookerTimezone: false,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
allowReschedulingCancelledBookings: false,
@@ -469,6 +476,8 @@ describe("handleChildrenEventTypes", () => {
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
useBookerTimezone: false,
restrictionScheduleId: null,
parentId: 1,
userId: 5,
users: {
@@ -493,6 +502,8 @@ describe("handleChildrenEventTypes", () => {
customReplyToEmail: null,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
useBookerTimezone: false,
restrictionScheduleId: null,
hashedLink: {
deleteMany: {},
},
@@ -213,9 +213,6 @@ export type InputEventType = {
userId?: number;
minimumBookingNotice?: number;
useEventLevelSelectedCalendars?: boolean;
/**
* These user ids are `ScenarioData["users"]["id"]`
*/
users?: { id: number }[];
hosts?: InputHost[];
schedulingType?: SchedulingType;
@@ -237,6 +234,25 @@ export type InputEventType = {
owner?: number;
metadata?: any;
rescheduleWithSameRoundRobinHost?: boolean;
restrictionSchedule?: {
create: {
name: string;
timeZone: string;
availability: {
createMany: {
data: {
days: number[];
startTime: Date;
endTime: Date;
date: string | null;
}[];
};
};
user: { connect: { id: number } };
};
};
useBookerTimezone?: boolean;
restrictionScheduleId?: number;
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
@@ -310,19 +326,31 @@ export async function addEventTypesToDb(
"users" | "workflows" | "destinationCalendar" | "schedule"
> & {
id?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users?: any[];
userId?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hosts?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
destinationCalendar?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schedule?: any;
metadata?: any;
team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean };
restrictionSchedule?: {
create: {
name: string;
timeZone: string;
availability: {
createMany: {
data: {
days: number[];
startTime: Date;
endTime: Date;
date: string | null;
}[];
};
};
user: { connect: { id: number } };
};
};
})[]
) {
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
@@ -338,11 +366,6 @@ export async function addEventTypesToDb(
},
});
/**
* This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship.
* HACK STARTS
*/
log.silly("Fixed possible prismock bug by creating schedule separately");
for (let i = 0; i < eventTypes.length; i++) {
const eventType = eventTypes[i];
const createdEventType = allEventTypes[i];
@@ -350,8 +373,6 @@ export async function addEventTypesToDb(
if (eventType.schedule) {
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
await prismock.schedule.create({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
data: {
...eventType.schedule.create,
eventType: {
@@ -363,6 +384,24 @@ export async function addEventTypesToDb(
});
}
// Handle restrictionSchedule creation and connection
if (eventType.restrictionSchedule) {
const createdRestrictionSchedule = await prismock.schedule.create({
data: {
...eventType.restrictionSchedule.create,
eventType: {
connect: {
id: createdEventType.id,
},
},
},
});
await prismock.eventType.update({
where: { id: createdEventType.id },
data: { restrictionScheduleId: createdRestrictionSchedule.id },
});
}
if (eventType.team?.id) {
const createdTeam = await prismock.team.create({
data: {
@@ -379,9 +418,6 @@ export async function addEventTypesToDb(
});
}
}
/***
* HACK ENDS
*/
log.silly(
"TestData: All EventTypes in DB are",
@@ -454,6 +490,22 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
},
}
: eventType.schedule,
restrictionScheduleId: eventType.restrictionScheduleId ?? undefined,
useBookerTimezone: eventType.useBookerTimezone ?? false,
restrictionSchedule: eventType.restrictionSchedule
? {
create: {
name: eventType.restrictionSchedule.create.name,
timeZone: eventType.restrictionSchedule.create.timeZone,
availability: {
createMany: {
data: eventType.restrictionSchedule.create.availability.createMany.data,
},
},
user: { connect: { id: eventType.userId || (eventType.users && eventType.users[0]?.id) || 1 } },
},
}
: undefined,
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
schedulingType: eventType.schedulingType,
parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined,

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