Compare commits

..
52 Commits
Author SHA1 Message Date
Alex van Andel 20efb566b2 chore: release v5.9.12
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-10 01:09:32 +00:00
d9ebf26e21 chore: standarize rate limit structure (#25736)
* feat: add toggle to opt out of booking title translation in instant meetings (#25547)

* feat: add toggle to opt out of booking title translation in instant meetings

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

* fix: add autoTranslateTitleEnabled to test builder

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

* fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService

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

* fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService

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

* refactor: rename autoTranslateTitleEnabled to autoTranslateInstantMeetingTitleEnabled

- Renamed field to clarify it only applies to instant meeting title translation
- Moved Prisma query to EventTypeRepository.findInstantMeetingConfigById()
- Removed title translation logic from update.handler.ts (flag only controls instant meeting title)
- Updated all references across the codebase
- Added new i18n translation keys for the renamed field

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

* fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns

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

* fix: use Prisma.TeamCreateInput type for metadata in test helper

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

* fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test

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

* refactor: use generic findByIdMinimal instead of business-specific method

- Add autoTranslateInstantMeetingTitleEnabled to eventTypeSelect constant
- Remove findInstantMeetingConfigById from EventTypeRepository
- Update InstantBookingCreateService to use findByIdMinimal

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

* refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal)

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

* feat: add autoTranslateInstantMeetingTitleEnabled to event type form defaults

- Rename shouldTranslateTitle to shouldAutoTranslateInstantMeetingTitle for clarity
- Add autoTranslateInstantMeetingTitleEnabled to findById and findByIdForOrgAdmin selects
- Add autoTranslateInstantMeetingTitleEnabled to useEventTypeForm defaultValues

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

* fix: only update autoTranslateInstantMeetingTitleEnabled when explicitly provided

Fixes issue where omitting the field in update requests would overwrite
the saved opt-out setting with the default value (true). Now the field
is only included in the update payload when explicitly provided.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: Create standard for rate limits

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 01:04:21 +00:00
Volnei Munhoz cb13874574 Fix react version for lingo.dev (#25740) 2025-12-10 01:01:37 +00:00
Volnei Munhoz 1fa02d957e sync yarn.lock (#25737) 2025-12-09 20:19:22 +00:00
Benny JooandDevin AI 529ca159f7 refactor: remove TRPCError handling from getServerErrorFromUnknown (#25691)
* refactor: remove TRPCError handling from getServerErrorFromUnknown

This removes the @trpc/server dependency from packages/lib by removing
TRPCError handling from getServerErrorFromUnknown.ts.

packages/lib is meant to be a general utility library that other packages
depend on, so it shouldn't import tRPC. The callers that need to handle
TRPCError (like onErrorHandler.ts and defaultResponder.ts) already handle
it explicitly before calling getServerErrorFromUnknown, so this change
has no behavioral impact.

Changes:
- Remove TRPCError and getHTTPStatusCodeFromError imports
- Remove TRPCError instanceof check from getServerErrorFromUnknown
- Update docstring to note that TRPCError is not handled
- Remove TRPCError-related tests (they belong in the tRPC layer)
- Fix lint warnings in Prisma error tests (replace 'as any' with proper type)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* wip

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 16:21:55 -03:00
devin-ai-integration[bot]andlauris@cal.com be12ac75b4 docs: add managed organizations documentation (#25735)
- Create docs/platform/guides/managed-orgs.mdx with guide for creating managed organizations and their OAuth clients via the API
- Add entry to docs/mint.json navigation menu in the Platform Guides section

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
2025-12-09 18:49:54 +00:00
Eunjae LeeandDevin AI 68cdfd42b1 docs: add Repository + DTO pattern guidance for Prisma type isolation (#25730)
* docs: add Repository + DTO pattern guidance for Prisma type isolation

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

* docs: add 'Why this pattern?' section explaining benefits of Repository + DTO

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

* docs: condense Repository + DTO pattern guidance for readability

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 18:26:02 +00:00
Pedro CastroandUdit Takkar 3ebac0194e fix: block Google Meet installation without Google Calendar dependency (#25513)
* fix: block Google Meet installation without Google Calendar dependency

  - Prevent users from installing Google Meet when Google Calendar is not connected
  - Update AppDependencyComponent to show error state for unmet dependencies
  - Fix InstallAppButtonChild to respect disabled prop from dependencies check
  - Ensure consistent dependency validation across App Store and app detail pages

  Fixes #25497

* test: add unit tests for dependency validation
- Add tests for AppDependencyComponent (8 tests)
- Add tests for InstallAppButtonChild (5 tests)
- Cover dependency blocking behavior
- Test visual state changes (bg-error/bg-subtle)

* test: resolve type-check error in InstallAppButtonChild tests

- Add complete MockCredential type definition
- Create factory function to generate typed mock credentials
- Eliminate code duplication across test cases

* fix: remove redundant disabled prop assignment

Disabled state is already passed through via props.disableInstall
which gets converted to props.disabled in InstallAppButtonWithoutPlanCheck.
This change addresses the review comment to avoid unnecessary duplication

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-12-09 17:56:20 +00:00
Rajiv Sahal 6bdf320640 fix: dont pass any default values for new oauth client creation (#25734) 2025-12-09 16:34:59 +00:00
Lauris Skraucis cdfdce533e chore: atoms release changelog and package.json (#25733) 2025-12-09 16:28:33 +00:00
PallavandAmit Sharma 5e29a7fa2a feat: add option to ignore guests in HubSpot integration and update related schemas (#25727)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2025-12-09 15:21:33 +00:00
Pallavandcubic-dev-ai[bot] 97377c4554 docs: updated the steps to connect hubspot (#25724)
* updated the stpes to connect the app

* chore

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-12-09 15:06:43 +00:00
Eunjae LeeandDevin AI acc82f808d docs: instruct to use ast-grep > ripgrep > grep for searching (#25725)
* docs: instruct to use ripgrep with grep fallback

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

* docs: update search tool preference to ast-grep > ripgrep > grep

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 14:13:06 +00:00
3a59cd41cc feat: add toggle to opt out of booking title translation in instant meetings (#25547)
* feat: add toggle to opt out of booking title translation in instant meetings

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

* fix: add autoTranslateTitleEnabled to test builder

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

* fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService

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

* fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService

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

* refactor: rename autoTranslateTitleEnabled to autoTranslateInstantMeetingTitleEnabled

- Renamed field to clarify it only applies to instant meeting title translation
- Moved Prisma query to EventTypeRepository.findInstantMeetingConfigById()
- Removed title translation logic from update.handler.ts (flag only controls instant meeting title)
- Updated all references across the codebase
- Added new i18n translation keys for the renamed field

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

* fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns

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

* fix: use Prisma.TeamCreateInput type for metadata in test helper

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

* fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test

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

* refactor: use generic findByIdMinimal instead of business-specific method

- Add autoTranslateInstantMeetingTitleEnabled to eventTypeSelect constant
- Remove findInstantMeetingConfigById from EventTypeRepository
- Update InstantBookingCreateService to use findByIdMinimal

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

* refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal)

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

* feat: add autoTranslateInstantMeetingTitleEnabled to event type form defaults

- Rename shouldTranslateTitle to shouldAutoTranslateInstantMeetingTitle for clarity
- Add autoTranslateInstantMeetingTitleEnabled to findById and findByIdForOrgAdmin selects
- Add autoTranslateInstantMeetingTitleEnabled to useEventTypeForm defaultValues

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

* fix: only update autoTranslateInstantMeetingTitleEnabled when explicitly provided

Fixes issue where omitting the field in update requests would overwrite
the saved opt-out setting with the default value (true). Now the field
is only included in the update payload when explicitly provided.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 13:51:51 +00:00
Anik Dhabal Babu 6ae2ce8ed9 fix: workflow email is not sending on form submission (#25722)
* fix: email not sending on form submission

* Remove handleCheckoutSessionExpired function

Removed the handleCheckoutSessionExpired function and its registration in webhookHandlers.
2025-12-09 13:24:41 +00:00
Anik Dhabal Babu 476460dad8 fix: Add custom payload template support for form submission webhooks (#25719)
* fix: Add custom payload template support for form submission webhooks

* delete

* Update applyTemplate function signature

Refactor applyTemplate function to accept specific data type.
2025-12-09 13:22:13 +00:00
Rajiv Sahal 19cadd18e3 fix: preserve description for event type (#25726) 2025-12-09 13:05:24 +00:00
Hariom Balhara e20cc960f1 feat: add uuid plumbing from API v2 to packages/features (#25721)
## What does this PR do?

This PR adds user UUID plumbing from API v2 controllers through to packages/features functions. This is preparatory work extracted from PR #25125 to support future audit logging functionality.

**Key changes:**
- API v2 controllers (2024-04-15, 2024-08-13) now extract and pass `userUuid` to downstream functions
- `handleMarkNoShow` and `CancelBookingInput` types now accept optional `userUuid` parameter
- `UserRepository.findUnlockedUserForSession` now selects `uuid` field
- Session middleware now includes `uuid` in the returned user object
- Fixed lint warning: changed `PromiseSettledResult<any>` to `PromiseSettledResult<unknown>`

**Refactoring (optimization):**
- Renamed `getOwnerId` → `getOwner` and `getOwnerIdRescheduledBooking` → `getOwnerRescheduledBooking`
- These methods now return `{ id: number; uuid: string } | null` instead of just `number | undefined`
- This eliminates redundant database calls by fetching user id and uuid in a single query

**Important:** This is plumbing-only - packages/features receives the `userUuid` but does not use it directly (note the `_userUuid` prefix). The actual audit logging usage will come in a follow-up PR.

Requested by: @hariombalhara (hariom@cal.com)

Link to Devin run: https://app.devin.ai/sessions/545209189f6347cd807bf1b336f9ac40

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - no documentation changes needed.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. N/A - plumbing only, existing tests cover the functionality.

## How should this be tested?

1. Run type checks: `yarn type-check:ci --force`
2. Verify the changes compile without new type errors related to uuid
3. The `userUuid` parameters are optional, so existing functionality should work unchanged

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have checked if my changes generate no new warnings

## Human Review Checklist

- [ ] Verify the `userUuid` parameter is intentionally unused (prefixed with `_userUuid`) - this is plumbing for future audit logging
- [ ] Verify all callers of `getOwner` and `getOwnerRescheduledBooking` correctly handle the new `{ id, uuid } | null` return type
- [ ] Confirm the change from `undefined` to `null` as the "not found" return value is handled consistently
- [ ] Verify the `uuid` field exists in the User model schema
2025-12-09 17:29:55 +05:30
Hariom BalharaandDevin AI f350542d0d feat: add booking audit action services for lib/actions folder (#25720)
Extract lib/actions folder changes from booking-audit-more-infra branch:
- AcceptedAuditActionService
- AttendeeAddedAuditActionService
- AttendeeNoShowUpdatedAuditActionService
- AttendeeRemovedAuditActionService
- CancelledAuditActionService
- CreatedAuditActionService (modified)
- HostNoShowUpdatedAuditActionService
- IAuditActionService (modified)
- LocationChangedAuditActionService
- ReassignmentAuditActionService
- RejectedAuditActionService
- RescheduleRequestedAuditActionService
- RescheduledAuditActionService

Also includes common/changeSchemas.ts required for type-checks to pass.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 14:29:41 +05:30
Peer RichelsenandDhairyashil Shinde 257a49ce88 fix(companion): replaced localhost with prod url (#25576)
* replaced localhost with prod url

* nit

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-09 07:47:17 +00:00
sean-brydon 75b93cebdb fix: troubleshooter team events + improve race condition (#25704)
* fix query to bring back teams + fix hover state

* fix team issues with troubleshooter
2025-12-09 07:37:21 +00:00
Anik Dhabal Babu 161ebdbbec perf: Fix N+1 queries and optimize database operations (#25630)
* perf: batch database operations and fix N+1 queries

* delete

* Fix condition for checking CalVideo location activity
2025-12-09 07:32:30 +00:00
Kartik Labhshetwar 978c5bdb95 fix(insights): align call history empty state with search (#25718) 2025-12-09 07:19:10 +00:00
Alex van Andel 895d039efc chore: release v5.9.11
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-09 03:08:28 +00:00
Anik Dhabal Babu 9f566bc8fc fix: orgbanner (#25706) 2025-12-08 20:35:11 +05:30
Volnei Munhoz 518e541177 chore: upgrade husky (#25698)
* bump husky version

* fix new husky version warning

* Fix husky version
2025-12-08 10:47:29 -03:00
Anik Dhabal Babu c1be922ee1 perf: Workflows are taking too long to load (#25705)
* perf: workflows are too slow

* Refactor eventTypeRepository for improved clarity

* Reintroduce ErrorCode and ErrorWithCode imports
2025-12-08 18:31:46 +05:30
Volnei Munhoz 967bba1e80 Remove coderrabit config (#25702) 2025-12-08 12:27:28 +00:00
Hariom Balhara 0fc26f782f feat: Add support to audit and view audit log with Booking CREATED action (#25468)
## What does this PR do?

This PR introduces the booking audit infrastructure with a viewer service to fetch, enrich, and format audit logs. This is the first iteration that includes only the CREATED action with versioned schema

Note: Additional audit actions, UI improvements to match with Figma will be taken care of in followup PRs, to ensure the PR size remains as small as possible(as it is large enough already)

Demo - Loom - https://www.loom.com/share/22c2f38b052b480b8be3716e1608eaf6

### Key Changes

**New Booking Audit Package** (`packages/features/booking-audit/`):

- `BookingAuditViewerService` - Fetches, enriches, and formats audit logs for display
- `BookingAuditTaskConsumer` - Processes audit tasks asynchronously via Tasker
- `CreatedAuditActionService` (v1) - Handles RECORD_CREATED action with versioned schema

**Repository Layer**:

- `BookingAuditRepository` - CRUD operations for audit records
- `AuditActorRepository` - Actor management (users, guests, system)
- `UserRepository.findByUuid()` - User lookup for actor enrichment

**UI Components**:

- New page at `/booking/logs/[bookinguid]` for viewing audit history
- Filterable audit log list with search, type, and actor filters
- Expandable log entries showing detailed change information

**Infrastructure**:

- `bookingAudit` Tasker task type for async processing
- `booking-audit` feature flag (disabled by default)
- tRPC endpoint `viewer.bookings.getAuditLogs`

### Updates since last revision

- **Refactored `Task` class to `TaskRepository`**: Converted all static methods to instance methods following the repository pattern. A singleton `Task` is exported for backward compatibility, so existing call sites continue to work unchanged.

### Important Notes for Reviewers

- **Permission check is TODO**: `checkPermissions()` in `BookingAuditViewerService` is not yet implemented
- **Only CREATED action supported**: Other actions will throw an error - additional actions will be added iteratively in follow-up PRs
- **Feature flag controlled**: System is behind `booking-audit` flag, disabled by default
- **UI strings need i18n**: Some UI text in `booking-logs-view.tsx` may need translation

### Human Review Checklist

- [ ] Verify permission enforcement strategy for audit log access
- [ ] Review DI wiring in module files
- [ ] Confirm error handling in `BookingAuditTaskConsumer` is appropriate for retry scenarios
- [ ] Check if UI strings need to be added to translation files
- [ ] Verify `TaskRepository` refactoring maintains backward compatibility (singleton export `Task` should work for all existing call sites)

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - internal infrastructure
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

Run the integration tests:

```bash
TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditViewerService.integration-test.ts
TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.integration-test.ts
```

To test the UI:

1. Enable the `booking-audit` feature flag in the database
2. Create a booking (only CREATED action is supported in this PR)
3. Navigate to `/booking/logs/{bookingUid}` to view the audit trail

## Checklist

- [x] I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- [x] My code follows the style guidelines of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have checked if my changes generate no new warnings

<!-- Link to Devin run: https://app.devin.ai/sessions/a8ae61c253b549429401c9651dcbcf44 -->
<!-- Requested by: hariom@cal.com (@hariombalhara) -->
2025-12-08 17:38:58 +05:30
sean-brydon fadd824d12 feat: onboarding v3 QA final roast (#25667)
* remove leading none

* remove back button if coming from teams/org onboarding

* add skip now
2025-12-08 11:35:44 +00:00
Volnei MunhozandDevin AI c1b7b6e83d fix: prevent OOO dialog from closing on outside click (#25699)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 11:23:09 +00:00
Volnei Munhoz e82a53a442 add fallback discovery for oauth (#25656) 2025-12-08 11:15:31 +00:00
a6578fdcd2 perf: handleChildrenEventType link old event-type with workflows using create/findMany (#25653)
* perf: link old event-type with workflows using create/findMany

* test: fix handleChildrenEventTypes test mocks for new workflow linking implementation

Update test to match the new implementation that uses findMany + createMany
instead of upsert for linking workflows to old event types.

- Add mock for workflowsOnEventTypes.findMany to return empty array
- Update assertion from upsert to findMany + createMany

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 13:11:21 +02:00
Eunjae LeeandDevin AI 4e68bcb021 docs: improve AGENTS.md and rename .agents to agents/ (#25665)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 11:06:59 +00:00
1e673f1df4 perf: handleChildrenEventTypes createManyAndReturn (#25652)
* perf: handleChildrenEventTypes createManyAndReturn

* fix: update test mocks for createManyAndReturn

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

* fix: use full EventType shape in createManyAndReturn mock return values

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 10:21:57 +00:00
Morgan b91f27dc80 perf: handleChildrenEventType eventtype user connect raw sql (#25650)
* perf: handleChildrenEventType eventtype user connect raw sql

* fixup! perf: handleChildrenEventType eventtype user connect raw sql

* remove user connect with prisma
2025-12-08 11:41:25 +02:00
Benny Joo becae27b45 refactor: migrate verifyCodeUnAuthenticated from TRPC layer to features layer (#25639)
* rm

* update test

* verifyCodeUnAuthenticated

* wip
2025-12-08 10:54:46 +02:00
Benny JooandDevin AI 584f5581fa refactor: move handleNotificationWhenNoSlots from trpc to features/slots (#25538)
Move handleNotificationWhenNoSlots.ts and its test file from
packages/trpc/server/routers/viewer/slots/ to packages/features/slots/
to resolve circular dependency issue where packages/features imports
from packages/trpc.

This file is not TRPC-specific and belongs in the features package.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 10:54:21 +02:00
Anik Dhabal Babu e10c4c1cb5 fix: unable to reject the booking from email (#25673)
* fix: unable to reject the booking

* Refactor error handling for booking confirmation
2025-12-08 12:32:19 +05:30
Syed Ali Shahbaz c78552ce60 fix: OnDelete SetNull for watchlist delete on watchlistAudit (#25690)
* OnDelete SetNull for watchlist delete on watchlistAudit

* make watchlistId nullable

* type fix
2025-12-08 05:22:01 +00:00
Morgan 9631d0a64b chore: update add members to team (#25685) 2025-12-08 03:48:15 +00:00
1182460d5c chore: add permission checks to invite member and list team members handlers (#25679)
* chore: update invite team member handler

* chore: update invite team member handler

* fix: add mock for PermissionCheckService in inviteMember handler tests

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

* fix: add mocks for PBAC dependencies in inviteMember handler tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-07 16:32:55 +00:00
Alex van Andel aedc0f5a03 chore: release v5.9.10
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-07 14:25:55 +00:00
Keith Williams ff88ec0eae fix: Docker build OOM issues in v5.9.9 (#25678) 2025-12-07 14:22:24 +00:00
Kartik Labhshetwar 110b5ceaf6 style: adjust dark hover background for user page to subtle (#25662) 2025-12-06 13:24:41 +00:00
Kartik Labhshetwar 7c53d2c9ce fix: improve spacing in provide link to team member when OOO list (#25529) 2025-12-06 09:12:48 +00:00
Volnei Munhoz eccebff4c6 Companion build (#25648) 2025-12-05 21:22:31 +00:00
cal.com 0a36dd7f61 revert: perf: handleChildrenEventType update existing events without mapping 2025-12-05 22:24:15 +02:00
cal.com 5143083efb perf: handleChildrenEventType update existing events without mapping 2025-12-05 22:00:33 +02:00
AbhishekandDhairyashil Shinde b874e0d55a fix: data table toolbar styling (#25632)
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-05 16:01:05 +00:00
Dhairyashil Shinde 3ee24586a7 feat(api): PATCH Event Type V2 API to support all current locations (#25084)
* feat(api): PATCH Event Type V2 API to support all current locations

* docs(api): update locations documentation and add E2E tests for new integrations

- Updated locations property documentation in create-event-type.input.ts
  and update-event-type.input.ts to clarify app installation requirements
- Explained that only Google Meet, MS Teams, and Zoom can be installed via API
- Noted that Cal Video is installed by default
- Added E2E tests for creating and updating event types with newly supported
  integration locations (jitsi, zoom, google-meet, whereby, huddle, element-call)
- Regenerated openapi.json with updated API documentation

Addresses feedback from Lauris regarding platform API location support.

* fix(api): use supportedIntegrations list for app validation

Updated checkAppIsValidAndConnected to use the full supportedIntegrations
list from locations.input.ts instead of hardcoded array. This allows all
27 supported conferencing apps to be set as event type locations via API,
as long as they are already connected by the user.

* fix(api): add slug mapping for all conferencing integrations

Added comprehensive slug mapping to translate API integration names
(e.g., 'facetime-video', 'whereby-video') to actual app slugs
(e.g., 'facetime', 'whereby'). This ensures the app lookup works
correctly for all 27 supported conferencing integrations.

Addresses AI bot feedback about slug mismatches.

* fix(api): add missing huddle to huddle01 slug mapping

Added mapping for huddle -> huddle01. Other apps like tandem, jitsi,
cal-video, google-meet, and zoom don't need mapping as their API names
already match their app slugs (handled by the fallback || appSlug).

* update key

* update ket

* test(api): update E2E tests to validate newly supported integrations

Replaced end-to-end tests with validation-focused tests that follow
the existing pattern. The new test creates event types with various
newly supported integrations (jitsi, whereby-video, huddle, tandem,
element-call-video) directly in the database (bypassing app connection
checks) and verifies the API correctly returns them.

This approach tests that the input validation accepts all 27 supported
integration types without requiring actual app installations in the
test environment.

* fix(api): correct slug mappings for whatsapp, shimmer, and jelly integrations

- Fixed whatsapp-video mapping from 'whatsappvideo' to 'whatsapp'
- Fixed shimmer-video mapping from 'shimmer' to 'shimmervideo'
- Fixed jelly-conferencing mapping from 'jelly-conferencing' to 'jelly'

All slug mappings now correctly match the actual app slugs in
packages/app-store/*/config.json files. This ensures proper app
validation when users create/update event types with these locations.

Addresses feedback from @pedroccastro

* updated openapi.json file because of main branch code

* test(api): add negative test for unsupported integration locations

- Added E2E test to validate 400 error when creating event type with unsupported integration
- Test verifies exact error message listing all supported integrations
- Uses imported supportedIntegrations constant for maintainability
- Follows same pattern as booking fields validation tests

Addresses feedback from @supalarry

* test(api): add negative test for patching event type with unconnected integration

- Added E2E test to validate 400 error when user tries to PATCH event type with jitsi integration they haven't connected
- Test verifies exact error message 'jitsi not connected.'
- Follows existing test patterns with proper cleanup
2025-12-05 19:51:58 +05:30
Dhairyashil Shinde b014ded5fd feat: api v2 event types ordering - user, team, org (#25177)
* feat: api-v2-event-types-ordering

* sort team and org event types

* revert: remove accidental changes to api-auth.strategy.ts

* docs: add ordering documentation and test for event types endpoints

- Added test assertion to verify event types are returned in descending order by ID (newest first)
- Added API documentation to user event types endpoint describing default ordering behavior
- Added API documentation to team event types endpoint describing default ordering behavior
- Added API documentation to organization event types endpoints describing default ordering behavior

Addresses PR feedback to document and test the ordering behavior introduced in the API v2 event types ordering feature.

* feat: add optional sortCreatedAt parameter to event types endpoints

- Add sortCreatedAt query parameter (SortOrderType: "asc" | "desc") to all event types endpoints
- Define SortOrder enum and SortOrderType in pagination.input.ts for reusability
- When not provided, no explicit ordering is applied (backward compatible)
- Update user, team, and organization event types endpoints
- Add comprehensive e2e tests for all sorting scenarios
- Fix circular dependency in platform-types import
- Thread sortCreatedAt through all service layers
- Use spread pattern for conditional orderBy to avoid empty array issues

Addresses PR feedback to make ordering opt-in rather than changing default behavior
2025-12-05 19:51:48 +05:30
200 changed files with 5568 additions and 1179 deletions
-187
View File
@@ -1,187 +0,0 @@
language: en-US
tone_instructions: ''
early_access: false
enable_free_tier: true
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: false
high_level_summary_placeholder: '@coderabbitai summary'
high_level_summary_in_walkthrough: false
auto_title_placeholder: '@coderabbitai'
auto_title_instructions: ''
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: true
related_issues: false
related_prs: true
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
poem: false
labeling_instructions: []
path_filters:
- '!**/node_modules/**'
- '!**/dist/**'
- '!**/build/**'
- '!**/.next/**'
path_instructions:
- path: "**/*.{ts,tsx,js,jsx}"
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
abort_on_close: true
disable_cache: false
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
pre_merge_checks:
docstrings:
mode: warning
threshold: 80
title:
mode: warning
requirements: ''
description:
mode: warning
issue_assessment:
mode: warning
tools:
ast-grep:
rule_dirs: []
util_dirs: []
essential_rules: true
packages: []
shellcheck:
enabled: true
ruff:
enabled: true
markdownlint:
enabled: true
github-checks:
enabled: true
timeout_ms: 180000
languagetool:
enabled: true
enabled_rules: []
disabled_rules: []
enabled_categories: []
disabled_categories: []
enabled_only: false
level: default
biome:
enabled: true
hadolint:
enabled: true
swiftlint:
enabled: true
phpstan:
enabled: true
level: default
phpmd:
enabled: true
phpcs:
enabled: true
golangci-lint:
enabled: true
yamllint:
enabled: true
gitleaks:
enabled: true
checkov:
enabled: true
detekt:
enabled: true
eslint:
enabled: true
flake8:
enabled: true
rubocop:
enabled: true
buf:
enabled: true
regal:
enabled: true
actionlint:
enabled: true
pmd:
enabled: true
cppcheck:
enabled: true
semgrep:
enabled: true
circleci:
enabled: true
clippy:
enabled: true
sqlfluff:
enabled: true
prismaLint:
enabled: true
pylint:
enabled: true
oxc:
enabled: true
shopifyThemeCheck:
enabled: true
luacheck:
enabled: true
brakeman:
enabled: true
dotenvLint:
enabled: true
htmlhint:
enabled: true
checkmake:
enabled: true
osvScanner:
enabled: true
chat:
art: true
auto_reply: true
integrations:
jira:
usage: auto
linear:
usage: auto
knowledge_base:
opt_out: false
web_search:
enabled: true
code_guidelines:
enabled: true
filePatterns: []
learnings:
scope: auto
issues:
scope: auto
jira:
usage: auto
project_keys: []
linear:
usage: auto
team_keys: []
pull_requests:
scope: auto
mcp:
usage: auto
disabled_servers: []
code_generation:
docstrings:
language: en-US
path_instructions: []
unit_tests:
path_instructions: []
+4
View File
@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated
-3
View File
@@ -1,6 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged
yarn app-store:build && git add packages/app-store/*.generated.*
+194 -19
View File
@@ -1,30 +1,205 @@
# Cal.com Development Guide for AI Agents
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioritize type safety, security, and small, reviewable diffs.
## Quick Reference
## Do
### Essential Commands
- Use `select` instead of `include` in Prisma queries for performance and security
- Use `import type { X }` for TypeScript type imports
- Use early returns to reduce nesting: `if (!booking) return null;`
- Use TRPCError with proper error codes for API errors
- Use conventional commits: `feat:`, `fix:`, `refactor:`
- Create PRs in draft mode by default
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
- Put permission checks in `page.tsx`, never in `layout.tsx`
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
- `yarn dev` - Start development server
- `yarn build` - Build all packages
- `yarn lint:fix` - Lint and fix code
- `yarn type-check` - TypeScript checking
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
## Don't
## Tool Preferences
- Never use `as any` - use proper type-safe solutions instead
- Never expose `credential.key` field in API responses or queries
- Never commit secrets or API keys
- Never modify `*.generated.ts` files directly - they're created by app-store-cli
- Never put business logic in repositories - that belongs in Services
- Never use barrel imports from index.ts files
- Never skip running type checks before pushing
- Never create large PRs (>500 lines or >10 files) - split them instead
### Search Tools Priority
## Commands
Use tools in this order of preference:
### File-scoped (preferred for speed)
1. **ast-grep** - For AST-based code searches (if available)
2. **rg (ripgrep)** - For fast text searches
3. **grep** - As fallback for text searches
```bash
# Type check - always run on changed files
yarn type-check:ci --force
## 📚 Detailed Documentation
# Lint single file
yarn eslint --fix path/to/file.tsx
- **[.agents/README.md](.agents/README.md)** - Complete development guide
- **[.agents/commands.md](.agents/commands.md)** - All build, test & dev commands
- **[.agents/knowledge-base.md](.agents/knowledge-base.md)** - Knowledge base & best practices
# Format single file
yarn prettier --write path/to/file.tsx
# Unit test specific file
yarn vitest run path/to/file.test.ts
# Integration test specific file
yarn test path/to/file.integration-test.ts -- --integrationTestsOnly
# E2E test specific file
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts
```
### Project-wide (use sparingly)
```bash
# Development
yarn dev # Start dev server
yarn dx # Dev with database setup
# Build & check
yarn build # Build all packages
yarn lint:fix # Lint and fix all
yarn type-check # Type check all
# Tests (use TZ=UTC for consistency)
TZ=UTC yarn test # All unit tests
yarn e2e # All E2E tests
# Database
yarn prisma generate # Regenerate types after schema changes
yarn workspace @calcom/prisma db-migrate # Run migrations
```
## Boundaries
### Always do
- Run type check on changed files before committing
- Run relevant tests before pushing
- Use `select` in Prisma queries
- Follow conventional commits for PR titles
### Ask first
- Adding new dependencies
- Schema changes to `packages/prisma/schema.prisma`
- Changes affecting multiple packages
- Deleting files
- Running full build or E2E suites
### Never do
- Commit secrets, API keys, or `.env` files
- Expose `credential.key` in any query
- Use `as any` type casting
- Force push or rebase shared branches
- Modify generated files directly
## Project Structure
```
apps/web/ # Main Next.js application
packages/prisma/ # Database schema (schema.prisma) and migrations
packages/trpc/ # tRPC API layer (routers in server/routers/)
packages/ui/ # Shared UI components
packages/features/ # Feature-specific code
packages/app-store/ # Third-party integrations
packages/lib/ # Shared utilities
```
### Key files
- Routes: `apps/web/app/` (App Router)
- Database schema: `packages/prisma/schema.prisma`
- tRPC routers: `packages/trpc/server/routers/`
- Translations: `apps/web/public/static/locales/en/common.json`
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
## Tech Stack
- **Framework**: Next.js 13+ (App Router in some areas)
- **Language**: TypeScript (strict)
- **Database**: PostgreSQL with Prisma ORM
- **API**: tRPC for type-safe APIs
- **Auth**: NextAuth.js
- **Styling**: Tailwind CSS
- **Testing**: Vitest (unit), Playwright (E2E)
- **i18n**: next-i18next
## Code Examples
### Good error handling
```typescript
// Good - Descriptive error with context
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unable to create booking: User ${userId} has no available time slots for ${date}`,
});
// Bad - Generic error
throw new Error("Booking failed");
```
### Good Prisma query
```typescript
// Good - Use select for performance and security
const booking = await prisma.booking.findFirst({
select: {
id: true,
title: true,
user: {
select: {
id: true,
name: true,
email: true,
}
}
}
});
// Bad - Include fetches all fields including sensitive ones
const booking = await prisma.booking.findFirst({
include: { user: true }
});
```
### Good imports
```typescript
// Good - Type imports and direct paths
import type { User } from "@prisma/client";
import { Button } from "@calcom/ui/components/button";
// Bad - Regular import for types, barrel imports
import { User } from "@prisma/client";
import { Button } from "@calcom/ui";
```
## PR Checklist
- [ ] Title follows conventional commits: `feat(scope): description`
- [ ] Type check passes: `yarn type-check:ci --force`
- [ ] Lint passes: `yarn lint:fix`
- [ ] Relevant tests pass
- [ ] Diff is small and focused (<500 lines, <10 files)
- [ ] No secrets or API keys committed
- [ ] UI strings added to translation files
- [ ] Created as draft PR
## When Stuck
- Ask a clarifying question before making large speculative changes
- Propose a short plan for complex tasks
- Open a draft PR with notes if unsure about approach
- Fix type errors before test failures - they're often the root cause
- Run `yarn prisma generate` if you see missing enum/type errors
## Extended Documentation
For detailed information, see the `agents/` directory:
- **[agents/README.md](agents/README.md)** - Architecture overview and patterns
- **[agents/commands.md](agents/commands.md)** - Complete command reference
- **[agents/knowledge-base.md](agents/knowledge-base.md)** - Domain knowledge and best practices
- **[agents/coding-standards.md](agents/coding-standards.md)** - Coding standards with examples
+1 -1
View File
@@ -10,7 +10,7 @@ ARG CALCOM_TELEMETRY_DISABLED
ARG DATABASE_URL
ARG NEXTAUTH_SECRET=secret
ARG CALENDSO_ENCRYPTION_KEY=secret
ARG MAX_OLD_SPACE_SIZE=4096
ARG MAX_OLD_SPACE_SIZE=6144
ARG NEXT_PUBLIC_API_V2_URL
ARG CSP_POLICY
+2 -2
View File
@@ -836,12 +836,12 @@ following
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
2. From within the home of the Developer account page, go to "Manage apps".
3. Click "Create app" button top right.
3. Click "Create legacy app" button top right and select public app.
4. Fill in any information you want in the "App info" tab
5. Go to tab "Auth"
6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scopes called `crm.objects.contacts` and `crm.lists`.
9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
@@ -27,6 +27,12 @@ import { User } from "@prisma/client";
- Use React children and context instead of passing props through multiple components
### ORM and Types
- Never import `@calcom/prisma/client` in features, services, UI, or handlers.
- Use repository DTOs or domain types instead.
- See "Repository + DTO Pattern and Method Conventions" in the knowledge base for details and examples.
### Security Rules
```typescript
@@ -306,9 +306,54 @@ import { Button } from "@calcom/ui/components/button";
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
## Repository Method Conventions
## Repository + DTO Pattern and Method Conventions
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
We use a Repository + DTO pattern to isolate Prisma and the database from business logic. Repositories are the only layer that talks to Prisma and database models. All services, tRPC handlers, API controllers, workflows, and UI should depend on DTOs or domain types, not Prisma types.
DTOs (Data Transfer Objects) are simple TypeScript types or interfaces that represent our domain data in an ORM-agnostic way and are returned from repositories.
### Why this pattern?
- **Prevent type leaks**: Prisma type changes stay inside repositories instead of breaking services, handlers, and UI across dozens of files.
- **Enable safe refactors**: Business logic works against stable DTOs, so you can change storage details without touching higher layers.
- **Keep ORM swappable**: If we ever change ORM, only repository implementations change; services and handlers stay the same.
### What not to do: tight Prisma coupling in business logic
Do not import Prisma types in business logic. Prisma types belong only in the data access layer.
```typescript
// ❌ Bad – business logic depends on Prisma
import type { Webhook } from "@calcom/prisma/client";
export function sendPayload(webhook: Webhook) { /* ... */ }
// ✅ Good – business logic depends on DTO + repository
export interface WebhookDTO { id: string; url: string; }
export interface IWebhookRepository { findById(id: string): Promise<WebhookDTO | null>; }
export async function sendPayload(repo: IWebhookRepository, id: string) {
const webhook = await repo.findById(id);
// ...
}
```
### DTOs in repositories
Repositories should expose DTOs from their public methods and keep Prisma types internal. Inside the repository, use `Prisma.*GetPayload` and map rows to DTOs. The public methods should only return DTOs, never Prisma models.
```typescript
// DTO – ORM-agnostic type exported from the repository module
export interface BookingDTO {
id: string;
userId: number;
startTime: Date;
status: "PENDING" | "CONFIRMED" | "CANCELLED";
}
```
### Prisma boundaries
- **Allowed**: `packages/prisma`, repository implementations (`packages/features/**/repositories/*Repository.ts`), and low-level data access infrastructure.
- **Not allowed**: `packages/features/**` business logic (non-repository), `packages/trpc/**` handlers, `apps/web/**`, `apps/api/v2/**` services/controllers, and workflow/webhook/service layers.
### Method Naming Rules
@@ -125,7 +125,7 @@ export class BookingsController_2024_04_15 {
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly teamRepository: PrismaTeamRepository
) {}
) { }
@Get("/")
@UseGuards(ApiAuthGuard)
@@ -270,6 +270,7 @@ export class BookingsController_2024_04_15 {
const res = await handleCancelBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
userUuid: bookingRequest.userUuid,
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
platformClientId: bookingRequest.platformClientId,
platformCancelUrl: bookingRequest.platformCancelUrl,
@@ -300,7 +301,7 @@ export class BookingsController_2024_04_15 {
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@GetUser("id") userId: number,
@GetUser() user: UserWithProfile,
@Body() body: MarkNoShowInput_2024_04_15,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput_2024_04_15> {
@@ -309,7 +310,8 @@ export class BookingsController_2024_04_15 {
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
userId,
userId: user.id,
userUuid: user.uuid,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
@@ -378,7 +380,7 @@ export class BookingsController_2024_04_15 {
): Promise<ApiResponse<InstantBookingCreateResult>> {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
req.userId = (await this.getOwnerId(req)) ?? -1;
req.userId = (await this.getOwner(req))?.id ?? -1;
try {
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const instantMeeting = await this.instantBookingCreateService.createBooking({
@@ -405,30 +407,45 @@ export class BookingsController_2024_04_15 {
throw new InternalServerErrorException("Could not create instant booking.");
}
private async getOwnerId(req: Request): Promise<number | undefined> {
private async getOwner(req: Request): Promise<{ id: number; uuid: string } | null> {
try {
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
// Access Token
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
return ownerId;
}
if (!bearerToken) {
return null;
}
let ownerId: number | null = null;
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
ownerId = keyData?.userId ?? null;
} else {
// Access Token
ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
}
if (!ownerId) {
return null;
}
const user = await this.usersRepository.findById(ownerId);
if (!user) {
return null;
}
return { id: user.id, uuid: user.uuid };
} catch (err) {
this.logger.error(err);
return null;
}
}
private async getOwnerIdRescheduledBooking(
private async getOwnerRescheduledBooking(
request: Request,
platformClientId?: string
): Promise<number | undefined> {
): Promise<{ id: number; uuid: string } | null> {
if (
platformClientId &&
request.body.rescheduledBy &&
@@ -440,13 +457,15 @@ export class BookingsController_2024_04_15 {
);
}
if (request.body.rescheduledBy) {
if (request.body.rescheduledBy !== request.body.responses.email) {
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
if (request.body.rescheduledBy && request.body.rescheduledBy !== request.body.responses.email) {
const user = await this.usersRepository.findByEmail(request.body.rescheduledBy);
if (!user) {
return null;
}
return { id: user.id, uuid: user.uuid };
}
return undefined;
return null;
}
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
@@ -498,7 +517,8 @@ export class BookingsController_2024_04_15 {
}
}
const userId = await this.getOwnerId(req);
const owner = await this.getOwner(req);
const userId = owner?.id;
if (!userId) {
throw new UnauthorizedException(
@@ -561,12 +581,16 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = clone.body.rescheduleUid
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
: await this.getOwnerId(req);
const owner = clone.body.rescheduleUid
? await this.getOwnerRescheduledBooking(req, oAuthClientId)
: await this.getOwner(req);
const userId = owner?.id;
const userUuid = owner?.uuid;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
@@ -577,7 +601,7 @@ export class BookingsController_2024_04_15 {
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
clone.body = {
...clone.body,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
@@ -586,7 +610,7 @@ export class BookingsController_2024_04_15 {
if (oAuthClientId) {
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
}
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
}
async setPlatformAttendeesEmails(
@@ -612,9 +636,12 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const owner = await this.getOwner(req);
const userId = owner?.id ?? -1;
const userUuid = owner?.uuid;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
@@ -628,6 +655,7 @@ export class BookingsController_2024_04_15 {
});
Object.assign(clone, {
userId,
userUuid,
...oAuthParams,
platformBookingLocation,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
@@ -636,7 +664,7 @@ export class BookingsController_2024_04_15 {
if (oAuthClientId) {
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
}
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
}
private handleBookingErrors(
@@ -397,18 +397,18 @@ export class BookingsController_2024_08_13 {
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser("id") ownerId: number
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, user.id, body, user.uuid);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/reassign")
@HttpCode(HttpStatus.OK)
@@ -919,34 +919,40 @@ export class BookingsService_2024_08_13 {
return await this.getBooking(recurringBookingUid, authUser);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
async markAbsent(
bookingUid: string,
bookingOwnerId: number,
body: MarkAbsentBookingInput_2024_08_13,
userUuid?: string
) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
platformClientParams,
});
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
userUuid,
platformClientParams,
});
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
@@ -34,6 +34,7 @@ import { SchedulingType } from "@calcom/platform-libraries";
import {
BaseConfirmationPolicy_2024_06_14,
TeamEventTypeOutput_2024_06_14,
supportedIntegrations,
type ApiSuccessResponse,
type CreateEventTypeInput_2024_06_14,
type EventTypeOutput_2024_06_14,
@@ -640,9 +641,9 @@ describe("Event types Endpoints", () => {
hiddenEventType = responseBody.data;
});
it(`/GET/event-types by username`, async () => {
it(`/GET/event-types by username with sortCreatedAt=desc`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=desc`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
@@ -653,6 +654,12 @@ describe("Event types Endpoints", () => {
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
// Verify ordering: event types are returned newest to oldest when sortCreatedAt=desc
// hiddenEventType was created after eventType, so it should have a higher ID and appear first
expect(responseBody.data[0].id).toBeGreaterThan(responseBody.data[1].id);
expect(responseBody.data[0].id).toEqual(hiddenEventType.id);
expect(responseBody.data[1].id).toEqual(eventType.id);
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
@@ -711,6 +718,46 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.hidden).toEqual(false);
});
it(`/GET/event-types by username with sortCreatedAt=asc`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=asc`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
// Verify ordering: event types are returned oldest to newest when sortCreatedAt=asc
// eventType was created before hiddenEventType, so it should have a lower ID and appear first
expect(responseBody.data[0].id).toBeLessThan(responseBody.data[1].id);
expect(responseBody.data[0].id).toEqual(eventType.id);
expect(responseBody.data[1].id).toEqual(hiddenEventType.id);
});
it(`/GET/event-types by username without sortCreatedAt parameter`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
// Without sortCreatedAt, no specific order is guaranteed
// Just verify both event types are present
const ids = responseBody.data.map((et) => et.id);
expect(ids).toContain(eventType.id);
expect(ids).toContain(hiddenEventType.id);
});
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
@@ -2855,6 +2902,119 @@ describe("Event types Endpoints", () => {
});
});
it("should accept newly supported integration locations in input validation", async () => {
// Test that the API accepts various newly supported integrations by creating event types
// with these location types directly in the database and then retrieving them
// Note: Database uses underscores in integration types, API returns hyphens
const integrationTests = [
{ internal: "integrations:jitsi", api: "jitsi" },
{ internal: "integrations:whereby_video", api: "whereby-video" },
{ internal: "integrations:huddle01", api: "huddle" },
{ internal: "integrations:tandem", api: "tandem" },
{ internal: "integrations:element-call_video", api: "element-call-video" },
];
for (const { internal, api } of integrationTests) {
const eventTypeInput = {
title: `event type ${api}`,
description: "event type description",
length: 30,
hidden: false,
slug: `event-type-${api}`,
locations: [
{
type: internal,
link: `https://example.com/${api}/meeting`,
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
// Verify the integration location is returned correctly
expect(fetchedEventType.locations).toHaveLength(1);
expect(fetchedEventType.locations[0]).toMatchObject({
type: "integration",
integration: api,
});
// Clean up
await eventTypesRepositoryFixture.delete(legacyEventType.id);
}
});
it("should reject with 400 if creating event type with unsupported integration location", async () => {
const createPayload = {
title: "Event with unsupported integration",
slug: "event-with-unsupported-integration",
lengthInMinutes: 30,
locations: [
{
type: "integration",
integration: "unsupported-integration",
},
],
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(createPayload);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
`Validation failed for integration location: integration must be one of the following values: ${supportedIntegrations.join(
", "
)}`
);
});
it("should reject with 400 if patching event type with integration that user has not connected", async () => {
const createPayload = {
title: "Event for patch test",
slug: "event-patch-test-unconnected-integration",
lengthInMinutes: 30,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(createPayload)
.expect(201);
const createdEventType = createResponse.body.data;
const patchPayload = {
locations: [
{
type: "integration",
integration: "jitsi",
},
],
};
const patchResponse = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(patchPayload);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body.error.message).toBe("jitsi not connected.");
// Cleanup
await eventTypesRepositoryFixture.delete(createdEventType.id);
});
describe("EventType Hidden Property", () => {
let createdEventTypeId: number;
@@ -155,6 +155,8 @@ export class EventTypesController_2024_06_14 {
summary: "Get all event types",
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
Use the optional \`sortCreatedAt\` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@@ -3,6 +3,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
import type { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -67,21 +68,23 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getUserEventTypes(userId: number) {
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
return this.dbRead.prisma.eventType.findMany({
where: {
userId,
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
include: { users: true, schedule: true, destinationCalendar: true },
});
}
async getUserEventTypesPublic(userId: number) {
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
return this.dbRead.prisma.eventType.findMany({
where: {
userId,
hidden: false,
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -22,7 +22,7 @@ import {
getEventTypesPublic,
EventTypesPublic,
} from "@calcom/platform-libraries/event-types";
import type { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
import type { GetEventTypesQuery_2024_06_14, SortOrderType } from "@calcom/platform-types";
import type { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -160,15 +160,16 @@ export class EventTypesService_2024_06_14 {
orgSlug?: string;
orgId?: number;
authUser?: AuthOptionalUser;
sortCreatedAt?: SortOrderType;
}) {
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
if (!user) {
return [];
}
if (params.authUser?.id !== user.id) {
return await this.getUserEventTypesPublic(user.id);
return await this.getUserEventTypesPublic(user.id, params.sortCreatedAt);
}
return await this.getUserEventTypes(user.id);
return await this.getUserEventTypes(user.id, params.sortCreatedAt);
}
async getUserToCreateEvent(user: UserWithProfile) {
@@ -211,16 +212,16 @@ export class EventTypesService_2024_06_14 {
};
}
async getUserEventTypes(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId, sortCreatedAt);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
});
}
async getUserEventTypesPublic(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId, sortCreatedAt);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
@@ -237,7 +238,7 @@ export class EventTypesService_2024_06_14 {
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
const { username, eventSlug, usernames, orgSlug, orgId, sortCreatedAt } = queryParams;
if (username && eventSlug) {
const eventType = await this.getEventTypeByUsernameAndSlug({
username,
@@ -255,6 +256,7 @@ export class EventTypesService_2024_06_14 {
orgSlug,
orgId,
authUser,
sortCreatedAt,
});
}
@@ -264,7 +266,7 @@ export class EventTypesService_2024_06_14 {
}
if (authUser?.id) {
return await this.getUserEventTypes(authUser.id);
return await this.getUserEventTypes(authUser.id, sortCreatedAt);
}
return [];
@@ -39,6 +39,7 @@ import {
InputBookingField_2024_06_14,
OutputUnknownLocation_2024_06_14,
UpdateEventTypeInput_2024_06_14,
supportedIntegrations,
} from "@calcom/platform-types";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
@@ -542,14 +543,40 @@ export class InputEventTypesService_2024_06_14 {
}
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
const conferencingApps = ["google-meet", "office365-video", "zoom"];
const conferencingApps = supportedIntegrations as readonly string[];
if (!conferencingApps.includes(appSlug)) {
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
}
if (appSlug === "office365-video") {
appSlug = "msteams";
}
// Map API integration names to actual app slugs
const slugMap: Record<string, string> = {
"office365-video": "msteams",
"facetime-video": "facetime",
"whereby-video": "whereby",
"whatsapp-video": "whatsapp",
"webex-video": "webex",
"telegram-video": "telegram",
"sylaps-video": "sylapsvideo",
"skype-video": "skype",
"sirius-video": "sirius_video",
"signal-video": "signal",
"shimmer-video": "shimmervideo",
"salesroom-video": "salesroom",
"roam-video": "roam",
"riverside-video": "riverside",
"ping-video": "ping",
"mirotalk-video": "mirotalk",
"jelly-video": "jelly",
"jelly-conferencing": "jelly",
"huddle": "huddle01",
"element-call-video": "element-call",
"eightxeight-video": "eightxeight",
"discord-video": "discord",
"demodesk-video": "demodesk",
"campfire-video": "campfire",
};
appSlug = slugMap[appSlug] || appSlug;
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
@@ -5,6 +5,7 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
@@ -16,6 +17,7 @@ import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booki
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
import { TaskerService } from "@/lib/services/tasker.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module, Scope } from "@nestjs/common";
@@ -35,6 +37,7 @@ import { Module, Scope } from "@nestjs/common";
},
scope: Scope.TRANSIENT,
},
BookingAuditProducerService,
BookingEventHandlerService,
CheckBookingAndDurationLimitsService,
CheckBookingLimitsService,
@@ -45,6 +48,7 @@ import { Module, Scope } from "@nestjs/common";
BookingEmailAndSmsSyncTaskerService,
BookingEmailAndSmsTriggerTaskerService,
BookingEmailAndSmsTasker,
TaskerService,
RegularBookingService,
],
exports: [RegularBookingService],
@@ -0,0 +1,14 @@
import { TaskerService } from "@/lib/services/tasker.service";
import { Injectable } from "@nestjs/common";
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
constructor(taskerService: TaskerService) {
super({
tasker: taskerService.getTasker(),
});
}
}
@@ -4,14 +4,20 @@ import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@c
import { Logger } from "@/lib/logger.bridge";
import { BookingAuditProducerService } from "./booking-audit-producer.service";
import { HashedLinkService } from "./hashed-link.service";
@Injectable()
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
constructor(
hashedLinkService: HashedLinkService,
bridgeLogger: Logger,
bookingAuditProducerService: BookingAuditProducerService
) {
super({
log: bridgeLogger,
hashedLinkService,
bookingAuditProducerService,
});
}
}
@@ -0,0 +1,18 @@
import { Injectable } from "@nestjs/common";
import { getTasker, type Tasker } from "@calcom/platform-libraries";
@Injectable()
export class TaskerService {
private readonly tasker: Tasker;
constructor() {
this.tasker = getTasker();
}
getTasker(): Tasker {
return this.tasker;
}
}
@@ -47,8 +47,8 @@ import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
import {
CreateTeamEventTypeInput_2024_06_14,
GetOrganizationEventTypesQuery_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@@ -159,12 +159,16 @@ export class OrganizationsEventTypesController {
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/event-types")
@ApiOperation({ summary: "Get team event types" })
@ApiOperation({
summary: "Get team event types",
description:
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
})
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug, hostsLimit } = queryParams;
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
if (eventSlug) {
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(
@@ -179,7 +183,7 @@ export class OrganizationsEventTypesController {
};
}
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
return {
status: SUCCESS_STATUS,
@@ -191,16 +195,21 @@ export class OrganizationsEventTypesController {
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/event-types")
@ApiOperation({ summary: "Get all team event types" })
@ApiOperation({
summary: "Get all team event types",
description:
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
})
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
@Query() queryParams: GetOrganizationEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { skip, take } = queryParams;
const { skip, take, sortCreatedAt } = queryParams;
const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes(
orgId,
skip,
take
take,
sortCreatedAt
);
return {
@@ -1,16 +1,24 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesRepository {
constructor(private readonly dbRead: PrismaReadService) {}
async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) {
async getOrganizationTeamsEventTypes(
orgId: number,
skip: number,
take: number,
sortCreatedAt?: SortOrderType
) {
return this.dbRead.prisma.eventType.findMany({
where: {
team: {
parentId: orgId,
},
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
skip,
take,
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
@@ -1,4 +1,6 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import type { SortOrderType } from "@calcom/platform-types";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import {
TransformedCreateTeamEventTypeInput,
@@ -83,16 +85,22 @@ export class OrganizationsEventTypesService {
return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug, hostsLimit);
}
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesService.getTeamEventTypes(teamId);
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
}
async getOrganizationsTeamsEventTypes(
orgId: number,
skip = 0,
take = 250
take = 250,
sortCreatedAt?: SortOrderType
): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take);
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(
orgId,
skip,
take,
sortCreatedAt
);
}
async updateOrganizationTeamEventType(
@@ -136,12 +136,16 @@ export class TeamsEventTypesController {
}
@Get("/")
@ApiOperation({ summary: "Get a team event type" })
@ApiOperation({
summary: "Get team event types",
description:
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
})
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug, hostsLimit } = queryParams;
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
if (eventSlug) {
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(
@@ -156,7 +160,7 @@ export class TeamsEventTypesController {
};
}
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId);
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
return {
status: SUCCESS_STATUS,
@@ -11,6 +11,8 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
import { createEventType, updateEventType } from "@calcom/platform-libraries/event-types";
@Injectable()
@@ -101,8 +103,8 @@ export class TeamsEventTypesService {
return eventType;
}
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId);
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId, sortCreatedAt);
}
async updateTeamEventType(
@@ -2,6 +2,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { SortOrderType } from "@calcom/platform-types";
@Injectable()
export class TeamsEventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -79,11 +81,12 @@ export class TeamsEventTypesRepository {
});
}
async getTeamEventTypes(teamId: number) {
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType) {
return this.dbRead.prisma.eventType.findMany({
where: {
teamId,
},
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
include: {
users: true,
schedule: true,
@@ -0,0 +1,48 @@
// Added as a separate route for now to ease the testing of the audit logs feature
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
// TOOD: Move it to the booking page side bar later
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import BookingLogsView from "~/booking/logs/views/booking-logs-view";
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
await _generateMetadata(
(t) => t("booking_history"),
(t) => t("booking_history_description"),
undefined,
undefined,
`/booking/${(await params).uid}/logs`
);
const Page = async ({ params }: PageProps) => {
const resolvedParams = await params;
const bookingUid = resolvedParams.uid;
if (!bookingUid || typeof bookingUid !== "string") {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
return (
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
<BookingLogsView bookingUid={bookingUid} />
</ShellMainAppDir>
);
};
export default Page;
@@ -19,7 +19,7 @@ export const generateMetadata = async () => {
);
};
const ServerPage = async () => {
const ServerPage = async (props: { searchParams: Promise<{ fromTeamOnboarding?: string }> }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -28,8 +28,10 @@ const ServerPage = async () => {
const userEmail = session.user.email || "";
const userName = session.user.name || "";
const searchParams = await props.searchParams;
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
return <PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />;
};
export default ServerPage;
@@ -29,7 +29,7 @@ async function handler(req: NextRequest) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: piiHasher.hash(ip),
identifier: `forgotPassword:${piiHasher.hash(ip)}`,
});
try {
@@ -43,6 +43,7 @@ async function getHandler(req: NextRequest) {
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
if (isOnboardingV3Enabled && organizationOnboardingId) {
params.append("fromTeamOnboarding", "true");
const redirectUrl = new URL(
`/onboarding/personal/settings?${params.toString()}`,
WEBAPP_URL
@@ -64,7 +64,8 @@ async function postHandler(request: NextRequest) {
} catch (error) {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
{ status: 303 }
);
}
}
@@ -84,7 +85,8 @@ async function handleBookingAction(
if (!booking) {
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
{ status: 303 }
);
}
@@ -140,7 +142,10 @@ async function handleBookingAction(
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`);
return NextResponse.redirect(
`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`,
{ status: 303 }
);
}
await prisma.booking.update({
@@ -148,7 +153,7 @@ async function handleBookingAction(
data: { oneTimePassword: null },
});
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`);
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
}
export const GET = defaultResponderForAppDir(getHandler);
+1 -2
View File
@@ -184,8 +184,7 @@ export const AppPage = ({
enabled: !!dependencies,
});
const disableInstall =
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
const disableInstall = dependencyData.data ? dependencyData.data.some((dependency) => !dependency.installed) : false;
// const disableInstall = requiresGCal && !gCalInstalled.data;
@@ -0,0 +1,103 @@
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { InstallAppButtonChild } from "./InstallAppButtonChild";
// Mock credential type
type MockCredential = {
id: number;
delegatedToId: string;
userId: number;
user: { email: string };
key: { access_token: string };
invalid: boolean;
teamId: null;
team: null;
delegationCredentialId: string;
type:
| `${string}_calendar`
| `${string}_messaging`
| `${string}_payment`
| `${string}_video`
| `${string}_other`
| `${string}_automation`
| `${string}_analytics`
| `${string}_crm`
| `${string}_other_calendar`;
appId: string;
};
// Factory function to create mock credentials
const createMockCredential = (overrides: Partial<MockCredential> = {}): MockCredential => ({
id: 1,
type: "google_calendar" as const,
userId: 1,
delegatedToId: "delegation-123",
teamId: null,
team: null,
user: { email: "test@example.com" },
key: { access_token: "mock_token_123" },
delegationCredentialId: "delegation-123",
appId: "google-calendar",
invalid: false,
...overrides,
});
// Mock the useLocale hook
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string) => {
const translations = {
install_app: "Install App",
install_another: "Install Another",
start_paid_trial: "Start Free Trial",
subscribe: "Subscribe",
};
return translations[key as keyof typeof translations] || key;
},
}),
}));
describe("InstallAppButtonChild", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("is disabled when credentials exist and multiInstall is false", () => {
const mockCredentials = [createMockCredential()];
render(<InstallAppButtonChild multiInstall={false} credentials={mockCredentials} />);
const button = screen.getByTestId("install-app-button");
expect(button).toBeDisabled();
});
it("is enabled when credentials exist and multiInstall is true", () => {
const mockCredentials = [createMockCredential()];
render(<InstallAppButtonChild multiInstall={true} credentials={mockCredentials} />);
const button = screen.getByTestId("install-app-button");
expect(button).not.toBeDisabled();
});
it("is disabled when disabled prop is true regardless of credentials", () => {
render(<InstallAppButtonChild disabled={true} multiInstall={true} credentials={[]} />);
const button = screen.getByTestId("install-app-button");
expect(button).toBeDisabled();
});
it("combines disabled prop with credential logic correctly", () => {
const mockCredentials = [createMockCredential()];
render(<InstallAppButtonChild disabled={true} multiInstall={false} credentials={mockCredentials} />);
const button = screen.getByTestId("install-app-button");
expect(button).toBeDisabled();
});
it("is enabled when no blocking conditions exist", () => {
render(<InstallAppButtonChild multiInstall={false} credentials={[]} disabled={false} />);
const button = screen.getByTestId("install-app-button");
expect(button).not.toBeDisabled();
});
});
@@ -17,6 +17,7 @@ export const InstallAppButtonChild = ({
const { t } = useLocale();
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
const isDisabled = shouldDisableInstallation || props.disabled;
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
@@ -25,7 +26,7 @@ export const InstallAppButtonChild = ({
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
disabled={isDisabled}
color="primary"
size="base">
{paid.trial ? t("start_paid_trial") : t("subscribe")}
@@ -37,7 +38,7 @@ export const InstallAppButtonChild = ({
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
disabled={isDisabled}
color="primary"
size="base">
{multiInstall ? t("install_another") : t("install_app")}
@@ -0,0 +1,340 @@
/**
* TODO: Move it to features/booking-audit
*/
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import dayjs from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { FilterSearchField, Select } from "@calcom/ui/components/form";
interface BookingLogsViewProps {
bookingUid: string;
}
type AuditLog = {
id: string;
action: string;
type: string;
timestamp: string;
data: Record<string, unknown> | null;
actor: {
type: string;
displayName: string | null;
};
};
interface BookingLogsFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
typeFilter: string | null;
onTypeFilterChange: (value: string | null) => void;
actorFilter: string | null;
onActorFilterChange: (value: string | null) => void;
typeOptions: Array<{ label: string; value: string }>;
actorOptions: Array<{ label: string; value: string }>;
}
interface BookingLogsTimelineProps {
logs: AuditLog[];
}
const getActionIcon = (action: string) => {
switch (action) {
case "CREATED":
return "calendar";
case "CANCELLED":
case "REJECTED":
return "ban";
case "ACCEPTED":
return "check";
case "RESCHEDULED":
case "RESCHEDULE_REQUESTED":
return "pencil";
case "REASSIGNMENT":
case "ATTENDEE_ADDED":
case "ATTENDEE_REMOVED":
return "user-check";
case "LOCATION_CHANGED":
return "map-pin";
case "HOST_NO_SHOW_UPDATED":
case "ATTENDEE_NO_SHOW_UPDATED":
return "ban";
default:
return "sparkles";
}
};
function BookingLogsFilters({
searchTerm,
onSearchChange,
typeFilter,
onTypeFilterChange,
actorFilter,
onActorFilterChange,
typeOptions,
actorOptions,
}: BookingLogsFiltersProps) {
const { t } = useLocale();
return (
<div className="flex flex-wrap gap-2 items-start">
<div className="flex-1 min-w-[200px]">
<FilterSearchField
size="sm"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
containerClassName=""
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
value={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onTypeFilterChange(option.value || null);
}}
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onActorFilterChange(option.value || null);
}}
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
/>
</div>
</div>
);
}
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
const { t } = useLocale();
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
const toggleExpand = (logId: string) => {
setExpandedLogIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(logId)) {
newSet.delete(logId);
} else {
newSet.add(logId);
}
return newSet;
});
};
const toggleJson = (logId: string) => {
setShowJsonMap((prev) => ({
...prev,
[logId]: !prev[logId],
}));
};
if (logs.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted">{t("no_audit_logs_found")}</p>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{logs.map((log, index) => {
const isLast = index === logs.length - 1;
const isExpanded = expandedLogIds.has(log.id);
const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
const showJson = showJsonMap[log.id] || false;
return (
<div key={log.id} className="flex gap-1">
<div className="flex flex-col items-center self-stretch">
<div className="pt-2 shrink-0">
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
<Icon name={getActionIcon(log.action)} className="h-3 w-3 text-subtle" />
</div>
</div>
{!isLast && (
<div className="w-px bg-subtle flex-1 min-h-0" />
)}
</div>
<div className="flex-1 rounded-lg py-2">
<div className="px-3 mb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-emphasis leading-4">
{actionDisplay}
</h3>
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
<span>{log.actor.displayName}</span>
<span>•</span>
<span>{dayjs(log.timestamp).fromNow()}</span>
</div>
</div>
<Button
color="minimal"
size="sm"
variant="icon"
StartIcon="ellipsis"
className="h-6 w-6"
/>
</div>
</div>
<div className="px-3">
<div className="bg-muted rounded-[10px] py-1">
<Button
color="minimal"
size="sm"
onClick={() => toggleExpand(log.id)}
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
className="w-full justify-start text-xs font-medium text-subtle h-6">
{isExpanded ? t("hide_details") : t("show_details")}
</Button>
</div>
</div>
{isExpanded && (
<div className="mt-2 px-3">
<div className="space-y-2 text-xs">
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
<span className="text-default">{log.type}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="text-default">{log.actor.type}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">
{t("timestamp")}
</span>
<span className="text-default">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
</div>
{log.data && Object.keys(log.data).length > 0 && (
<div className="pt-2">
<Button
color="minimal"
size="sm"
onClick={() => toggleJson(log.id)}
StartIcon={showJson ? "chevron-down" : "chevron-right"}
className="mb-1 h-6 px-0 font-medium">
{t("json")}
</Button>
{showJson && (
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
{JSON.stringify(log.data, null, 2)}
</pre>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
}
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [actorFilter, setActorFilter] = useState<string | null>(null);
const { t } = useLocale();
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
bookingUid,
});
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
<Button className="mt-4" onClick={() => router.back()}>
{t("go_back")}
</Button>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-4">
<SkeletonText className="h-12 w-full" />
<SkeletonText className="h-24 w-full" />
<SkeletonText className="h-24 w-full" />
<SkeletonText className="h-24 w-full" />
</div>
);
}
const auditLogs = data?.auditLogs || [];
// Apply filters
const filteredLogs = auditLogs.filter((log) => {
const matchesSearch =
!searchTerm ||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = !typeFilter || log.action === typeFilter;
const matchesActor = !actorFilter || log.actor.type === actorFilter;
return matchesSearch && matchesType && matchesActor;
});
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
const typeOptions = uniqueTypes.map((type) => ({
label: t(`audit_action.${type.toLowerCase()}`),
value: type,
}));
const actorOptions = uniqueActorTypes.map((actorType) => ({
label: actorType,
value: actorType,
}));
return (
<div className="space-y-6">
<BookingLogsFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
actorFilter={actorFilter}
onActorFilterChange={setActorFilter}
typeOptions={typeOptions}
actorOptions={actorOptions}
/>
<BookingLogsTimeline logs={filteredLogs} />
</div>
);
}
@@ -312,14 +312,12 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) {
</>
}
EmptyView={
<div className="px-6 py-8">
<EmptyScreen
Icon="phone"
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("no_call_history")}
description={t("no_call_history_description")}
className="mb-16"
/>
</div>
}
/>
@@ -105,9 +105,9 @@ export function useCreateTeam() {
// Redirect to personal settings after successful invite
const gettingStartedPath = flags["onboarding-v3"]
? "/onboarding/personal/settings"
? "/onboarding/personal/settings?fromTeamOnboarding=true"
: "/getting-started";
router.push(gettingStartedPath);
router.replace(gettingStartedPath);
} catch (error) {
console.error("Failed to invite members:", error);
// Extract error message from TRPC error
@@ -81,6 +81,11 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
router.push("/onboarding/organization/teams");
};
const handleSkip = () => {
// Skip brand customization and go to teams
router.push("/onboarding/organization/teams");
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
{/* Left column - Main content */}
@@ -95,9 +100,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
onClick={() => router.push("/onboarding/organization/details")}>
{t("back")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
<div className="flex items-center gap-2">
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</div>
</div>
}>
{/* Form */}
@@ -37,9 +37,7 @@ export const InstallableAppCard = ({
</span>
)}
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
<p
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
title={app.name}>
<p className="text-default line-clamp-1 break-words text-left text-sm font-medium" title={app.name}>
{app.name}
</p>
<InstallAppButtonWithoutPlanCheck
@@ -26,9 +26,14 @@ import { useOnboardingStore } from "../../store/onboarding-store";
type PersonalSettingsViewProps = {
userEmail: string;
userName?: string;
fromTeamOnboarding?: boolean;
};
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
export const PersonalSettingsView = ({
userEmail,
userName,
fromTeamOnboarding = false,
}: PersonalSettingsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { data: user } = trpc.viewer.me.get.useQuery();
@@ -117,12 +122,14 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
subtitle={t("personal_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
{!fromTeamOnboarding && (
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
)}
<Button
type="submit"
form="personal-settings-form"
@@ -113,16 +113,16 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
}
} else {
// No invites, skip to personal settings
const gettingStartedPath = "/onboarding/personal/settings";
router.push(gettingStartedPath);
const gettingStartedPath = "/onboarding/personal/settings?fromTeamOnboarding=true";
router.replace(gettingStartedPath);
}
};
const handleSkip = async () => {
setTeamInvites([]);
// Skip inviting members and go to personal settings
const gettingStartedPath = "/onboarding/personal/settings";
router.push(gettingStartedPath);
const gettingStartedPath = "/onboarding/personal/settings?fromTeamOnboarding=true";
router.replace(gettingStartedPath);
};
const hasValidInvites = fields.some((_, index) => {
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { PERMISSION_MAP } from "@calcom/platform-constants";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
@@ -18,10 +17,8 @@ import type { FormValues } from "@components/settings/platform/oauth-clients/oau
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
export default function CreateOAuthClient() {
const searchParams = useCompatSearchParams();
const router = useRouter();
const { t } = useLocale();
const clientId = searchParams?.get("clientId") || "";
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
@@ -77,24 +74,7 @@ export default function CreateOAuthClient() {
</p>
</div>
</div>
<OAuthClientForm
isPending={isSaving}
onSubmit={onSubmit}
defaultValues={{
areCalendarEventsEnabled: true,
areEmailsEnabled: true,
eventTypeRead: true,
eventTypeWrite: true,
bookingRead: true,
bookingWrite: true,
scheduleRead: true,
scheduleWrite: true,
appsRead: true,
appsWrite: true,
profileRead: true,
profileWrite: true,
}}
/>
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
</div>
</Shell>
</div>
@@ -115,7 +115,7 @@ export function UserPage(props: PageProps) {
eventType: type,
});
}}
className="bg-default border-subtle dark:bg-cal-muted dark:hover:bg-emphasis hover:bg-cal-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
className="bg-default border-subtle dark:bg-cal-muted dark:hover:bg-subtle hover:bg-cal-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
data-testid="event-type-link">
<Icon
name="arrow-right"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.9.9",
"version": "5.9.12",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -124,11 +124,11 @@
"posthog-node": "^5.9.5",
"qrcode": "^1.5.1",
"raw-body": "^2.5.1",
"react": "^18.2.0",
"react": "18.2.0",
"react-colorful": "^5.6.0",
"react-date-picker": "^8.3.6",
"react-digit-input": "^2.1.0",
"react-dom": "^18.2.0",
"react-dom": "18.2.0",
"react-easy-crop": "^3.5.2",
"react-hook-form": "^7.43.3",
"react-live-chat-loader": "^2.8.1",
+1 -1
View File
@@ -36,7 +36,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: piiHasher.hash(userIp),
identifier: `createBooking:${piiHasher.hash(userIp)}`,
});
const session = await getServerSession({ req });
+1 -1
View File
@@ -38,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: piiHasher.hash(userIp),
identifier: `createRecurringBooking:${piiHasher.hash(userIp)}`,
});
const session = await getServerSession({ req });
/* To mimic API behavior and comply with types */
@@ -68,7 +68,7 @@ test.describe("Booking Confirmation and Rejection via API", () => {
maxRedirects: 0,
});
expect(response.status()).toBe(307);
expect(response.status()).toBe(303);
const location = response.headers()["location"];
expect(location).toContain(`/booking/${booking.uid}`);
@@ -138,7 +138,7 @@ test.describe("Booking Confirmation and Rejection via API", () => {
maxRedirects: 0,
});
expect(response.status()).toBe(307);
expect(response.status()).toBe(303);
const location = response.headers()["location"];
expect(location).toContain(`/booking/${booking.uid}`);
+18 -1
View File
@@ -3261,7 +3261,11 @@
"filter_operator_is_empty": "Is empty",
"filter_operator_not_empty": "Not empty",
"translate_description_button": "Auto translate title and description",
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.",
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor's browser language using AI.",
"auto_translate_booking_title": "Auto translate booking title",
"auto_translate_booking_title_description": "Automatically translate the booking title to the visitor's browser language. Disable this to keep the original title.",
"auto_translate_instant_meeting_title": "Auto translate instant meeting title",
"auto_translate_instant_meeting_title_description": "Automatically translate the instant meeting booking title to the visitor's browser language. Disable this to keep the original English title.",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"rr_distribution_method_availability_title": "Maximize availability",
@@ -4093,5 +4097,18 @@
"booking_response": "Booking Response",
"list_view": "List view",
"calendar_view": "Calendar view",
"booking_history": "Booking History",
"booking_history_description": "View the history of actions performed on this booking",
"audit_action": {
"created": "Created"
},
"error_loading_booking_logs": "Error loading booking logs",
"no_audit_logs_found": "No audit logs found",
"hide_details": "Hide details",
"show_details": "Show details",
"actor": "Actor",
"timestamp": "Timestamp",
"json": "JSON",
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -136,6 +136,7 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
...evType
@@ -148,8 +149,24 @@ describe("handleChildrenEventTypes", () => {
// Setup transaction mock to execute the callback
setupTransactionMock();
// Mock findMany to return empty array (no workflows to link)
prismaMock.eventType.findMany.mockResolvedValue([]);
// Mock createManyAndReturn to return the created event type (full EventType shape required for type safety)
const createdEventType = {
...evType,
id: 123,
userId: 4,
schedulingType,
teamId,
timeZone,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
@@ -162,7 +179,7 @@ describe("handleChildrenEventTypes", () => {
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
data: [
{
...expectedEvType,
@@ -183,6 +200,7 @@ describe("handleChildrenEventTypes", () => {
},
],
skipDuplicates: true,
select: { id: true, userId: true },
});
expect(result.newUserIds).toEqual([4]);
expect(result.oldUserIds).toEqual([]);
@@ -207,6 +225,7 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
assignRRMembersUsingSegment,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
...evType
@@ -316,6 +335,7 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
@@ -329,8 +349,25 @@ describe("handleChildrenEventTypes", () => {
// Setup transaction mock to execute the callback
setupTransactionMock();
// Mock findMany to return empty array (no workflows to link)
prismaMock.eventType.findMany.mockResolvedValue([]);
// Mock createManyAndReturn to return the created event type (full EventType shape required for type safety)
const createdEventType = {
...evType,
id: 123,
userId: 4,
schedulingType,
teamId,
timeZone,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
const result = await updateChildrenEventTypes({
@@ -344,7 +381,7 @@ describe("handleChildrenEventTypes", () => {
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
data: [
{
...expectedEvType,
@@ -366,6 +403,7 @@ describe("handleChildrenEventTypes", () => {
},
],
skipDuplicates: true,
select: { id: true, userId: true },
});
expect(result.newUserIds).toEqual([4]);
expect(result.oldUserIds).toEqual([]);
@@ -387,6 +425,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
@@ -454,6 +493,7 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
@@ -471,92 +511,28 @@ describe("handleChildrenEventTypes", () => {
// Setup transaction mock to execute the callback
setupTransactionMock();
// Mock findMany to return the newly created event type for workflow linking
// This simulates the event type created for user 5
prismaMock.eventType.findMany.mockResolvedValue([
{
id: 3,
title: "test",
slug: "test",
description: null,
position: 0,
locations: [],
length: 30,
offsetStart: 0,
hidden: false,
userId: 5,
profileId: null,
teamId: null,
eventName: null,
parentId: 1,
bookingFields: null,
timeZone: null,
periodType: "UNLIMITED",
periodStartDate: null,
periodEndDate: null,
periodDays: null,
periodCountCalendarDays: null,
lockTimeZoneToggleOnBookingPage: false,
lockedTimeZone: null,
requiresConfirmation: false,
requiresConfirmationWillBlockSlot: false,
requiresConfirmationForFreeEmail: false,
requiresBookerEmailVerification: false,
canSendCalVideoTranscriptionEmails: true,
autoTranslateDescriptionEnabled: false,
recurringEvent: null,
disableGuests: false,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
minimumBookingNotice: 120,
beforeEventBuffer: 0,
afterEventBuffer: 0,
seatsPerTimeSlot: null,
onlyShowFirstAvailableSlot: false,
showOptimizedSlots: false,
disableCancelling: false,
disableRescheduling: false,
seatsShowAttendees: false,
seatsShowAvailabilityCount: true,
schedulingType: null,
scheduleId: null,
price: 0,
currency: "usd",
slotInterval: null,
metadata: {},
successRedirectUrl: null,
forwardParamsSuccessRedirect: true,
bookingLimits: null,
durationLimits: null,
isInstantEvent: false,
instantMeetingExpiryTimeOffsetInSeconds: 90,
instantMeetingScheduleId: null,
assignAllTeamMembers: false,
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
eventTypeColor: null,
rescheduleWithSameRoundRobinHost: false,
rrSegmentQueryValue: null,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
restrictionScheduleId: null,
useBookerTimezone: false,
allowReschedulingCancelledBookings: false,
includeNoShowInRRCalculation: false,
interfaceLanguage: null,
customReplyToEmail: null,
createdAt: new Date(),
updatedAt: new Date(),
instantMeetingParameters: [],
isRRWeightsEnabled: false,
maxLeadThreshold: null,
allowReschedulingPastBookings: false,
hideOrganizerEmail: false,
maxActiveBookingsPerBooker: null,
maxActiveBookingPerBookerOfferReschedule: false,
bookingRequiresAuthentication: false,
},
]);
// Mock createManyAndReturn to return the newly created event type for workflow linking
// This simulates the event type created for user 5 (full EventType shape required for type safety)
const createdEventType = {
...evType,
id: 3,
userId: 5,
schedulingType: _schedulingType,
teamId: _teamId,
locations: _locations,
timeZone: _timeZone,
parentId: _parentId,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
autoTranslateInstantMeetingTitleEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
// Mock the event type that will be returned for existing users
const mockUpdatedEventType = {
@@ -565,6 +541,7 @@ describe("handleChildrenEventTypes", () => {
timeZone: "UTC",
teamId: 1,
autoTranslateDescriptionEnabled: false,
autoTranslateInstantMeetingTitleEnabled: true,
secondaryEmailId: null,
schedulingType: SchedulingType.MANAGED,
requiresBookerEmailVerification: false,
@@ -585,6 +562,10 @@ describe("handleChildrenEventTypes", () => {
updatedAt: new Date(),
});
// Mock workflowsOnEventTypes.findMany to return empty array (no existing workflow links for old event types)
// This is needed because the new implementation uses findMany + createMany instead of upsert
prismaMock.workflowsOnEventTypes.findMany.mockResolvedValue([]);
await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
@@ -601,9 +582,9 @@ describe("handleChildrenEventTypes", () => {
const { createdAt, updatedAt, ...expectedEvType } = evType;
if ("workflows" in expectedEvType) delete expectedEvType.workflows;
// Verify createMany was called for new users (user 5)
// Note: createMany doesn't support nested relations like workflows, so they're handled separately
expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({
// Verify createManyAndReturn was called for new users (user 5)
// Note: createManyAndReturn doesn't support nested relations like workflows, so they're handled separately
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
data: [
{
...expectedEvType,
@@ -627,6 +608,7 @@ describe("handleChildrenEventTypes", () => {
},
],
skipDuplicates: true,
select: { id: true, userId: true },
});
// Verify workflowsOnEventTypes.createMany was called for new users' workflows
@@ -658,19 +640,23 @@ describe("handleChildrenEventTypes", () => {
},
},
});
// Verify workflowsOnEventTypes.upsert was called for existing users' workflows
expect(prismaMock.workflowsOnEventTypes.upsert).toHaveBeenCalledWith({
create: {
eventTypeId: 2,
workflowId: 11,
},
update: {},
// Verify workflowsOnEventTypes.findMany was called to check existing relationships
expect(prismaMock.workflowsOnEventTypes.findMany).toHaveBeenCalledWith({
where: {
workflowId_eventTypeId: {
eventTypeId: 2,
workflowId: 11,
},
workflowId: { in: [11] },
eventTypeId: { in: [2] },
},
select: {
workflowId: true,
eventTypeId: true,
},
});
// Verify workflowsOnEventTypes.createMany was called for existing users' workflows
// Since findMany returned empty array, all workflow-eventType pairs should be created
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
data: [{ eventTypeId: 2, workflowId: 11 }],
skipDuplicates: false,
});
});
});
-4
View File
@@ -1,6 +1,2 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd companion
npx lint-staged
+83
View File
@@ -0,0 +1,83 @@
import { createReadStream, existsSync } from "fs";
import { promises as fs } from "fs";
import { IncomingMessage, ServerResponse } from "http";
import { extname, join, normalize } from "path";
const distDir = join(process.cwd(), "dist");
const mimeTypes: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json; charset=utf-8",
};
const getSafePath = (urlPath: string): string => {
const decodedPath = decodeURIComponent(urlPath.split("?")[0]);
const normalized = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, "");
return normalized.startsWith("/") ? normalized : `/${normalized}`;
};
const resolveFilePath = async (safePath: string): Promise<string> => {
let candidate = join(distDir, safePath);
try {
const stats = await fs.stat(candidate);
if (stats.isDirectory()) {
candidate = join(candidate, "index.html");
}
return candidate;
} catch {
const fallback = join(distDir, "index.html");
if (existsSync(fallback)) {
return fallback;
}
throw new Error("Dist folder missing. Run `npm run build` before deploying.");
}
};
const sendFile = (res: ServerResponse, filePath: string) => {
const ext = extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] || "application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Cache-Control",
ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable"
);
const stream = createReadStream(filePath);
stream.on("error", (error) => {
console.error("Failed to read file:", error);
res.statusCode = 500;
res.end("Internal Server Error");
});
stream.pipe(res);
};
export default async function handler(req: IncomingMessage, res: ServerResponse) {
if (req.method && !["GET", "HEAD"].includes(req.method)) {
res.statusCode = 405;
res.setHeader("Allow", "GET, HEAD");
res.end("Method Not Allowed");
return;
}
try {
const urlPath = req.url ?? "/";
const safePath = getSafePath(urlPath);
const filePath = await resolveFilePath(safePath);
sendFile(res, filePath);
} catch (error) {
console.error("Server render error:", error);
res.statusCode = 500;
res.end("Internal Server Error");
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export default defineContentScript({
// Create iframe
const iframe = document.createElement("iframe");
iframe.src = "http://localhost:8081";
iframe.src = "https://companion.cal.com";
iframe.style.width = "400px";
iframe.style.height = "100%";
iframe.style.border = "none";
+2 -2
View File
@@ -3,9 +3,9 @@
"name": "Cal.com Companion",
"version": "1.7.0",
"description": "Your calendar companion for quick booking and scheduling",
"permissions": ["activeTab", "http://localhost:8081/*", "identity"],
"permissions": ["activeTab", "https://companion.cal.com/*", "identity"],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;"
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com;"
},
"action": {
"default_title": "Cal.com Companion"
+28 -5
View File
@@ -290,14 +290,37 @@ export class CalComOAuthService {
}
private async getDiscoveryEndpoints(): Promise<AuthSession.DiscoveryDocument> {
const fallbackDiscovery: AuthSession.DiscoveryDocument = {
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
revocationEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/revoke`,
};
const isCrossOriginWeb =
Platform.OS === "web" &&
typeof window !== "undefined" &&
(() => {
try {
return new URL(this.config.calcomBaseUrl).origin !== window.location.origin;
} catch {
return true;
}
})();
// Skip discovery fetch when we know CORS will block it (e.g. companion.cal.com -> app.cal.com).
if (isCrossOriginWeb) {
return fallbackDiscovery;
}
try {
const discovery = await AuthSession.fetchDiscoveryAsync(this.config.calcomBaseUrl);
return discovery;
} catch {
return {
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
} as AuthSession.DiscoveryDocument;
...fallbackDiscovery,
...discovery,
};
} catch (error) {
console.warn("Failed to load discovery document, using fallback endpoints", error);
return fallbackDiscovery;
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"routes": [
{
"handle": "filesystem"
},
{
"src": "/.*",
"dest": "/index.html"
}
]
}
+2 -2
View File
@@ -11,14 +11,14 @@ export default defineConfig({
description: "Your calendar companion for quick booking and scheduling",
permissions: ["activeTab", "storage", "identity"],
host_permissions: [
"http://localhost:8081/*",
"https://companion.cal.com/*",
"https://api.cal.com/*",
"https://app.cal.com/*",
"https://mail.google.com/*",
],
content_security_policy: {
extension_pages:
"script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;",
"script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com",
},
action: {
default_title: "Cal.com Companion",
+94 -6
View File
@@ -2819,6 +2819,7 @@
"organization.attributes.update",
"organization.attributes.delete",
"organization.attributes.create",
"organization.attributes.editUsers",
"routingForm.create",
"routingForm.read",
"routingForm.update",
@@ -4470,6 +4471,7 @@
"get": {
"operationId": "OrganizationsEventTypesController_getTeamEventTypes",
"summary": "Get team event types",
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
"parameters": [
{
"name": "Authorization",
@@ -4523,6 +4525,16 @@
"schema": {
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
}
],
"responses": {
@@ -4816,6 +4828,7 @@
"get": {
"operationId": "OrganizationsEventTypesController_getTeamsEventTypes",
"summary": "Get all team event types",
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
"parameters": [
{
"name": "Authorization",
@@ -4876,6 +4889,16 @@
"default": 0,
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
}
],
"responses": {
@@ -11552,7 +11575,7 @@
"get": {
"operationId": "EventTypesController_2024_06_14_getEventTypes",
"summary": "Get all event types",
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.\n \n Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -11609,6 +11632,16 @@
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
@@ -14420,6 +14453,7 @@
"get": {
"operationId": "TeamsEventTypesController_getTeamEventTypes",
"summary": "Get a team event type",
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
"parameters": [
{
"name": "teamId",
@@ -14446,6 +14480,16 @@
"schema": {
"type": "number"
}
},
{
"name": "sortCreatedAt",
"required": false,
"in": "query",
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
}
],
"responses": {
@@ -16781,7 +16825,37 @@
"integration": {
"type": "string",
"example": "cal-video",
"enum": ["cal-video", "google-meet", "office365-video", "zoom"]
"enum": [
"cal-video",
"google-meet",
"zoom",
"whereby-video",
"whatsapp-video",
"webex-video",
"telegram-video",
"tandem",
"sylaps-video",
"skype-video",
"sirius-video",
"signal-video",
"shimmer-video",
"salesroom-video",
"roam-video",
"riverside-video",
"ping-video",
"office365-video",
"mirotalk-video",
"jitsi",
"jelly-video",
"jelly-conferencing",
"huddle",
"facetime-video",
"element-call-video",
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video"
]
}
},
"required": ["type", "integration"]
@@ -17989,7 +18063,7 @@
},
"locations": {
"type": "array",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
"items": {
"oneOf": [
{
@@ -19920,7 +19994,7 @@
},
"locations": {
"type": "array",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
"items": {
"oneOf": [
{
@@ -21561,7 +21635,7 @@
},
"locations": {
"type": "array",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
"items": {
"oneOf": [
{
@@ -22024,7 +22098,7 @@
},
"locations": {
"type": "array",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
"items": {
"oneOf": [
{
@@ -26294,6 +26368,7 @@
"organization.attributes.update",
"organization.attributes.delete",
"organization.attributes.create",
"organization.attributes.editUsers",
"routingForm.create",
"routingForm.read",
"routingForm.update",
@@ -26397,6 +26472,7 @@
"organization.attributes.update",
"organization.attributes.delete",
"organization.attributes.create",
"organization.attributes.editUsers",
"routingForm.create",
"routingForm.read",
"routingForm.update",
@@ -26528,6 +26604,7 @@
"organization.attributes.update",
"organization.attributes.delete",
"organization.attributes.create",
"organization.attributes.editUsers",
"routingForm.create",
"routingForm.read",
"routingForm.update",
@@ -26630,6 +26707,7 @@
"organization.attributes.update",
"organization.attributes.delete",
"organization.attributes.create",
"organization.attributes.editUsers",
"routingForm.create",
"routingForm.read",
"routingForm.update",
@@ -27467,6 +27545,14 @@
"type": "string",
"nullable": true
},
"avatarUrl": {
"type": "string",
"nullable": true
},
"bio": {
"type": "string",
"nullable": true
},
"timeFormat": {
"type": "number"
},
@@ -27493,6 +27579,8 @@
"username",
"email",
"name",
"avatarUrl",
"bio",
"timeFormat",
"defaultScheduleId",
"weekStart",
+1
View File
@@ -248,6 +248,7 @@
"platform/guides/replacing-toasts",
"platform/guides/booking-fields",
"platform/guides/booking-redirects",
"platform/guides/managed-orgs",
"platform/guides/teams-setup",
"platform/guides/global-event-types",
"platform/guides/custom-booking-flow"
+117
View File
@@ -0,0 +1,117 @@
---
title: Managed organizations
description: Find out how to create managed organizations and their OAuth clients via the api.
---
## **1. How to create a managed organizations**
First, manually create an OAuth client in [https://app.cal.com/settings/platform](https://app.cal.com/settings/platform).
Second, make a POST request to `https://api.cal.com/v2/organizations/:organizationId/organizations` to create a managed organization and receive its api key:
1. Replace `:organizationId` in the URL with your organization Id that you can copy from OAuth client settings.
2. Add authentication headers `x-cal-client-id` equal to OAuth client id and `x-cal-secret-key` equal to the OAuth client secret.
3. Add request body
```json
{
"name": "some organization",
"metadata": {
"some": "metadata"
},
"apiKeyDaysValid": 60
}
```
Required fields: "name" - name of the managed organization.
Optional fields: "metadata", "apiKeyDaysValid", "apiKeyNeverExpires". By default the api key of a managed organization is valid for 30 days which means that you have to rotate them (will explain how below). If you want that api key is valid, for example, 60 days then specify that using "apiKeyDaysValid: 60" or if you want that api key never expires, set "apiKeyNeverExpires: true" in the request body.
Here is response:
```json
{
"status": "success",
"data": {
"id": 148,
"name": "some organization",
"metadata": {
"some": "metadata"
},
"apiKey": "cal_xxx168ceb89117cc3a1b1f1777511506"
}
}
```
Notably, you will only have access to the `apiKey` only now, so make sure to create a table in your database where you store managed organization's `id` and `apiKey` . The `apiKey` will be used to create an OAuth client that is used to create platform users.
Using same `x-cal-client-id` and `x-cal-secret-key` headers you can make requests to:
1. Fetch managed organization by making GET request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId`
2. Fetch all managed organizations by making GET request to `https://api.cal.com/v2/organizations/:organizationId/organizations`
3. Update managed organization by making PATCH request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId` with body where you can specify either "name" or "metadata"
4. Delete managed organization by making DELETE request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId`
Shortly, the flow is that using OAuth credentials of manager organization you create a managed organization and using its api key you can create and managed OAuth clients of the managed organization, and for the rest of the things like creating "managed users" of managed organization you will use OAuth client credentials.
## **2. How to create OAuth client for a managed organization**
Now that you have created a managed organization and have its `apiKey` you can create OAuth clients for the managed organization.
Make POST request to `https://api.cal.com/v2/oauth-clients` by:
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is api key returned when creating a managed organization.
2. Setting request body:
1. name is name of the OAuth client
2. permissions - an array that can receive following permissions: `"EVENT_TYPE_READ", "EVENT_TYPE_WRITE", "BOOKING_READ", "BOOKING_WRITE", "SCHEDULE_READ", "SCHEDULE_WRITE", "APPS_READ", "APPS_WRITE", "PROFILE_READ", "PROFILE_WRITE"` or if you want all of them enabled set `*` . You most probably want to use `*` . See docs here [https://cal.com/docs/platform/quickstart#2-setting-up-an-oauth-client](https://cal.com/docs/platform/quickstart#2-setting-up-an-oauth-client)
3. redirectUris - point to (as example) `your-domain.com*` where users end up when connecting google calendar etc and what is the origin of the requests aka what origins are allowed to make requests to our endpoints. Having `your-domain.com*` would mean that we will accept requests from cal UI components that make requests from `your-domain.com, your-domain.com/page` etc.
```json
{
"name": "OAuth client for managed organization",
"redirectUris": ["https://your-domain.com*"],
"permissions": ["*"]
}
```
Response:
```json
{
"status": "success",
"data": {
"clientId": "xxxepq3bg0000khghtq4bxxx",
"clientSecret": "xxxhbGciOiJIUzI1NiIsInR5cCI6IlpXVCJ9.eyJuYW1lIjoiT0F1dGggY2xpZW50IGZvciBtYW5hZ2VkIG9yZ2FuaXphdGlvbiIsInJlZGlyZWN0VXJpcyI6wyJodHRwOi8vbG9jYWxob3N0OjQzMjEiXSwicGVybWlzc2lvbnMiOjY0LCJpYXQiOjE3NDAxMzg4MjB9.3nzplyvqf-ZlMUuhX9mYfpxtneH62iJPB6WpsYBWxxx"
}
}
```
To fetch all OAuth clients of a managed organization make a GET request to `https://api.cal.com/v2/oauth-clients` by:
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is the api key returned when creating a managed organization.
To fetch a specific OAuth client of a managed organization make a GET request to `https://api.cal.com/v2/oauth-clients/:oAuthClientId` by:
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is the api key returned when creating a managed organization.
## **3. How to refresh api key of a managed organization**
If you did not specify `apiKeyNeverExpires` when creating a managed organization then you will have to refresh the api key after `apiKeyDaysValid` (if you specified it) or by default after 30 days.
Make a POST request to `https://api.cal.com/v2/api-keys/refresh` by:
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is api key returned when creating a managed organization.
2. Add a request body `{"apiKeyDaysValid": 30}` or whatever number of days you want the new api key to be valid or `{"apiKeyNeverExpires": true}` if you want that it never expires.
The response will contain the new api key for the managed organization:
```json
{
"status": "success",
"data": {
"apiKey": "cal_xxx467709fffebe8c6559211cb5f178f"
}
}
```
and now you have to update in your database api key of the managed organization.
+3 -2
View File
@@ -100,7 +100,7 @@
"checkly": "latest",
"dotenv-checker": "^1.1.5",
"eslint": "9.36.0",
"husky": "^8.0.0",
"husky": "9.1.7",
"i18n-unused": "^0.13.0",
"jest-diff": "^29.5.0",
"jest-summarizing-reporter": "^1.1.4",
@@ -139,7 +139,8 @@
"dayjs@1.11.2": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"dayjs@^1": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"dayjs@^1.8.29": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"import-in-the-middle": "1.13.1"
"import-in-the-middle": "1.13.1",
"react@19.2.0": "19.2.1"
},
"engines": {
"npm": ">=7.0.0",
@@ -0,0 +1,177 @@
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { AppDependencyComponent } from "./AppDependencyComponent";
// Type for dependency data
type MockDependency = {
name: string;
slug: string;
installed: boolean;
};
// Mock the useLocale hook
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string, values?: Record<string, unknown>) => {
if (key === "app_is_connected") {
return `${values?.dependencyName} is connected`;
}
if (key === "this_app_requires_connected_account") {
return `${values?.appName} requires a connected ${values?.dependencyName} account`;
}
if (key === "connect_app") {
return `Connect ${values?.dependencyName}`;
}
return key;
},
}),
}));
// Mock constants and UI components
vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@calcom/ui/components/icon", () => ({
Icon: ({ name, className }: { name: string; className?: string }) => (
<svg data-testid={`${name}-icon`} className={className}>
{name}
</svg>
),
}));
describe("AppDependencyComponent", () => {
// Factory function to reduce duplication
const createMockDependency = (overrides: Partial<MockDependency> = {}): MockDependency => ({
name: "Google Calendar",
slug: "google-calendar",
installed: true,
...overrides,
});
afterEach(() => {
vi.clearAllMocks();
});
it("shows success indicators when dependencies are met", () => {
const dependency = createMockDependency({ installed: true });
const { container } = render(
<AppDependencyComponent
appName="Google Meet"
dependencyData={[dependency]}
/>
);
expect(container.firstChild).toHaveClass("bg-subtle");
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
expect(screen.getByText("Google Calendar is connected")).toBeInTheDocument();
expect(screen.getByText("Google Meet requires a connected Google Calendar account"))
.toBeInTheDocument();
});
it("shows error indicators and connect link when dependencies are not met", () => {
const dependency = createMockDependency({ installed: false });
const { container } = render(
<AppDependencyComponent
appName="Google Meet"
dependencyData={[dependency]}
/>
);
expect(container.firstChild).toHaveClass("bg-error");
expect(screen.getByTestId("circle-x-icon")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /connect google calendar/i }))
.toHaveAttribute("href", expect.stringContaining("/apps/google-calendar"));
expect(screen.getByText("Google Meet requires a connected Google Calendar account"))
.toBeInTheDocument();
});
it("shows mixed states when some dependencies are unmet", () => {
const dependencies = [
createMockDependency({ installed: true }),
createMockDependency({ installed: false }),
];
const { container } = render(
<AppDependencyComponent
appName="Google Meet"
dependencyData={dependencies}
/>
);
expect(container.firstChild).toHaveClass("bg-error");
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
expect(screen.getByTestId("circle-x-icon")).toBeInTheDocument();
});
it("shows only success indicators when all dependencies are met", () => {
const dependencies = [
createMockDependency({ installed: true }),
createMockDependency({ name: "Zoom", slug: "zoom", installed: true }),
];
const { container } = render(
<AppDependencyComponent
appName="Google Meet"
dependencyData={dependencies}
/>
);
expect(container.firstChild).toHaveClass("bg-subtle");
const checkIcons = screen.getAllByTestId("check-icon");
expect(checkIcons).toHaveLength(2);
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
});
it("handles empty dependency data gracefully", () => {
const { container } = render(
<AppDependencyComponent appName="Google Meet" dependencyData={undefined} />
);
expect(container.firstChild).toHaveClass("bg-subtle");
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
});
it("handles empty dependency array gracefully", () => {
const { container } = render(
<AppDependencyComponent appName="Google Meet" dependencyData={[]} />
);
expect(container.firstChild).toHaveClass("bg-subtle");
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
});
it("treats truthy non-boolean values as installed", () => {
const dependency = createMockDependency({
installed: "yes" as unknown as boolean
});
const { container } = render(
<AppDependencyComponent
appName="Google Meet"
dependencyData={[dependency]}
/>
);
expect(container.firstChild).toHaveClass("bg-subtle");
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
expect(screen.getByText("Google Calendar is connected")).toBeInTheDocument();
});
it("displays requirement message consistently", () => {
const dependency = createMockDependency({ installed: true });
render(
<AppDependencyComponent
appName="Google Meet"
dependencyData={[dependency]}
/>
);
expect(
screen.getByText("Google Meet requires a connected Google Calendar account")
).toBeInTheDocument();
});
});
@@ -16,11 +16,13 @@ export const AppDependencyComponent = ({
}) => {
const { t } = useLocale();
const hasUnmetDependencies = dependencyData ? dependencyData.some((dep) => !dep.installed) : false;
return (
<div
className={classNames(
"rounded-md px-4 py-3",
dependencyData && dependencyData.some((dependency) => !dependency.installed) ? "bg-cal-info" : "bg-subtle"
hasUnmetDependencies ? "bg-error" : "bg-subtle"
)}>
{dependencyData &&
dependencyData.map((dependency) => {
@@ -50,9 +52,9 @@ export const AppDependencyComponent = ({
</div>
) : (
<div className="items-start space-x-2.5">
<div className="text-info flex items-start">
<div className="text-error flex items-start">
<div>
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
<Icon name="circle-x" className="mr-2 mt-1 font-semibold" />
</div>
<div>
<span className="font-semibold">
@@ -62,13 +64,12 @@ export const AppDependencyComponent = ({
interpolation: { escapeValue: false },
})}
</span>
<div>
<div>
<>
<Link
href={`${WEBAPP_URL}/apps/${dependency.slug}`}
className="text-info flex items-center underline">
className="text-error flex items-center underline">
<span className="mr-1">
{t("connect_app", { dependencyName: dependency.name })}
</span>
@@ -1,15 +1,28 @@
import { usePathname } from "next/navigation";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType, onAppInstallSuccess }) {
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
app,
eventType,
onAppInstallSuccess,
}) {
const pathname = usePathname();
const { t } = useLocale();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const { enabled, updateEnabled } = useIsAppEnabled(app);
const ignoreGuests = getAppData("ignoreGuests") ?? false;
return (
<AppCard
onAppInstallSuccess={onAppInstallSuccess}
@@ -20,9 +33,26 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
updateEnabled(e);
}}
switchChecked={enabled}
hideAppCardOptions
/>
hideSettingsIcon>
<Section.Content>
<Section.SubSection>
<Section.SubSectionHeader
icon="user-plus"
title={t("hubspot_ignore_guests")}
labelFor="ignore-guests">
<Switch
size="sm"
labelOnLeading
checked={ignoreGuests}
onCheckedChange={(checked) => {
setAppData("ignoreGuests", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
</Section.Content>
</AppCard>
);
};
export default EventTypeAppCard;
export default EventTypeAppCard;
+7 -2
View File
@@ -6,6 +6,7 @@ import type {
SimplePublicObject,
SimplePublicObjectInput,
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import type { z } from "zod";
import { getLocation } from "@calcom/lib/CalEventParser";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
@@ -20,6 +21,7 @@ import type { CRM, ContactCreateInput, Contact, CrmEvent } from "@calcom/types/C
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { HubspotToken } from "../api/callback";
import type { appDataSchema } from "../zod";
export default class HubspotCalendarService implements CRM {
private url = "";
@@ -29,8 +31,9 @@ export default class HubspotCalendarService implements CRM {
private client_id = "";
private client_secret = "";
private hubspotClient: hubspot.Client;
private appOptions: z.infer<typeof appDataSchema>;
constructor(credential: CredentialPayload) {
constructor(credential: CredentialPayload, appOptions?: z.infer<typeof appDataSchema>) {
this.hubspotClient = new hubspot.Client();
this.integrationName = "hubspot_other_calendar";
@@ -38,6 +41,8 @@ export default class HubspotCalendarService implements CRM {
this.auth = this.hubspotAuth(credential).then((r) => r);
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.appOptions = appOptions || {};
}
private getHubspotMeetingBody = (event: CalendarEvent): string => {
@@ -300,7 +305,7 @@ export default class HubspotCalendarService implements CRM {
}
getAppOptions() {
console.log("No options implemented");
return this.appOptions;
}
async handleAttendeeNoShow() {
+3 -1
View File
@@ -2,7 +2,9 @@ import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod;
export const appDataSchema = eventTypeAppCardZod.extend({
ignoreGuests: z.boolean().optional(),
});
export const appKeysSchema = z.object({
client_id: z.string().min(1),
+1 -1
View File
@@ -56,7 +56,7 @@ export const sendEmailVerification = async ({
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: hashEmail(email),
identifier: `sendEmailVerification:${hashEmail(email)}`,
});
await prisma.verificationToken.create({
+43 -31
View File
@@ -361,20 +361,20 @@ Used when a booking status changes to accepted.
#### ATTENDEE_ADDED
```typescript
{
addedAttendees // { old: null, new: ["email@example.com", ...] }
attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] }
}
```
Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state.
Tracks attendee(s) that were added in this action. The field stores the state change: `old` contains attendees before addition, `new` contains attendees after addition. The actual added attendees are computed as the difference (new - old).
#### ATTENDEE_REMOVED
```typescript
{
removedAttendees // { old: null, new: ["email@example.com", ...] }
attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] }
}
```
Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state.
Tracks attendee(s) that were removed in this action. The field stores the state change: `old` contains attendees before removal, `new` contains remaining attendees after removal. The actual removed attendees are computed as the difference (old - new).
---
@@ -671,14 +671,19 @@ The audit system is designed to capture what actually happened, not to enforce b
// ❌ BAD: Enforcing expected values
if (booking.status === 'ACCEPTED' || booking.status === 'PENDING' || booking.status === 'AWAITING_HOST') {
// Only audit if status is "expected"
await auditService.onBookingCreated(...);
await auditTaskConsumer.onBookingAction(...);
}
// ✅ GOOD: Recording actual state
await auditService.onBookingCreated(bookingId, userUuid, {
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
status: booking.status // Whatever it actually is
await bookingAuditProducerService.queueAudit({
bookingUid,
actor,
action: "CREATED",
data: {
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
status: booking.status // Whatever it actually is
}
});
```
@@ -728,40 +733,47 @@ await prisma.auditActor.update({
`BookingEventHandlerService` is the primary entry point for tracking any booking changes. It acts as a coordinator that:
1. **Receives booking events** from various parts of the application (booking creation, status changes, updates, etc.)
2. **Relays to BookingAuditService** to create audit records
2. **Queues audit tasks** via BookingAuditProducerService for asynchronous processing
3. **Handles other side effects** such as webhooks, notifications, and workflow triggers
### BookingAuditService
### BookingAuditTaskConsumer
The audit system is accessed through `BookingAuditService`, which provides:
The audit system processes audit records through `BookingAuditTaskConsumer`, which provides:
### Convenience Methods
### Core Method
- `onBookingCreated()` - Track booking creation
- `onBookingAccepted()` - Track acceptance
- `onBookingRejected()` - Track rejection
- `onBookingCancelled()` - Track cancellation
- `onBookingRescheduled()` - Track reschedule
- `onAttendeeAdded()` - Track attendee addition
- `onAttendeeRemoved()` - Track attendee removal
- `onLocationChanged()` - Track location changes
- `onHostNoShowUpdated()` - Track host no-show
- `onAttendeeNoShowUpdated()` - Track attendee no-show
- `onReassignment()` - Track booking reassignment
- `onRescheduleRequested()` - Track reschedule requests
- `onBookingAction(params)` - Generic method that handles any booking action
- Accepts a single object parameter with `bookingUid`, `actor`, `action`, and `data`
- Derives the record type (RECORD_CREATED, RECORD_UPDATED, RECORD_DELETED) from the action
- Routes to appropriate action service for data validation and formatting
### Record Type Mapping
- `getRecordType({ action })` - Maps booking actions to audit record types:
- `CREATED` → `RECORD_CREATED`
- Future actions like `CANCELLED`, `RESCHEDULED` → `RECORD_UPDATED`
- Future actions like `DELETED` → `RECORD_DELETED`
### Actor Management
- `getOrCreateUserActor()` - Ensures User actors exist before creating audits
- Automatic AuditActor creation/lookup for registered users
- `resolveActorId()` - Resolves Actor objects to actor IDs in the AuditActor table
- Automatic AuditActor creation/lookup for registered users, guests, and attendees
- System actor for automated actions
### Future: Trigger.dev Task Orchestration
**Current Flow (Synchronous):**
**Current Flow (Asynchronous with Task Queue):**
```
Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditService, linkService, webhookService
Booking Endpoint
↓
BookingEventHandler.onBookingCreated() [orchestrator]
├─ bookingAuditProducerService.queueAudit({ bookingUid, actor, action, data })
├─ hashedLinkService.validateAndIncrementUsage(hashedLink)
└─ Other side effects...
Task Queue
└─ Task: Booking Audit → BookingAuditTaskConsumer.onBookingAction({ bookingUid, actor, action, data })
```
**Future Flow (Async with Trigger.dev):**
@@ -769,14 +781,14 @@ Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditServi
Booking Endpoint
↓
BookingEventHandler.onBookingCreated() [orchestrator]
├─ tasks.trigger('bookingAudit', { bookingId, userUuid, data })
├─ tasks.trigger('bookingAudit', { bookingUid, actor, action, data })
├─ tasks.trigger('invalidateHashedLink', { bookingId, hashedLink })
├─ tasks.trigger('sendNotifications', { bookingId, email, sms })
├─ tasks.trigger('triggerWorkflows', { bookingId, event: 'NEW_EVENT' })
└─ Immediately returns to user (non-blocking)
Trigger.dev Queue
├─ Task: Booking Audit (with retries, monitoring)
├─ Task: Booking Audit → BookingAuditTaskConsumer.onBookingAction({ bookingUid, actor, action, data }) (with retries, monitoring)
├─ Task: Hashed Link Invalidation (independent)
├─ Task: Email & SMS Notifications (independent)
└─ Task: Workflow Triggers (independent)
@@ -5,8 +5,8 @@ import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/
import { createModule } from "../../di/di";
export const actorRepositoryModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY_MODULE;
const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: actorRepositoryModule,
moduleToken,
@@ -0,0 +1,24 @@
import { PrismaAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/PrismaAuditActorRepository";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { bindModuleToClassOnToken } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { createModule } from "../../di/di";
export const auditActorRepositoryModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: auditActorRepositoryModule,
moduleToken,
token,
classs: PrismaAuditActorRepository,
depsMap: {
prismaClient: prismaModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule,
};
@@ -0,0 +1,15 @@
import { createContainer } from "@calcom/features/di/di";
import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface";
import {
moduleLoader as bookingAuditTaskerProducerServiceModule,
} from "./BookingAuditTaskerProducerService.module";
const container = createContainer();
export function getBookingAuditProducerService() {
bookingAuditTaskerProducerServiceModule.loadModule(container);
return container.get<BookingAuditProducerService>(bookingAuditTaskerProducerServiceModule.token);
}
@@ -1,26 +0,0 @@
import { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as actorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/ActorRepository.module";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
export const bookingAuditServiceModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: bookingAuditServiceModule,
moduleToken,
token,
classs: BookingAuditService,
depsMap: {
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
actorRepository: actorRepositoryModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule
};
@@ -0,0 +1,15 @@
import { createContainer } from "@calcom/features/di/di";
import type { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer";
import {
moduleLoader as bookingAuditTaskConsumerModule,
} from "./BookingAuditTaskConsumer.module";
const container = createContainer();
export function getBookingAuditTaskConsumer() {
bookingAuditTaskConsumerModule.loadModule(container);
return container.get<BookingAuditTaskConsumer>(bookingAuditTaskConsumerModule.token);
}
@@ -0,0 +1,29 @@
import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
export const bookingAuditTaskConsumerModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_TASK_CONSUMER;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_TASK_CONSUMER_MODULE;
const loadModule = bindModuleToClassOnToken({
module: bookingAuditTaskConsumerModule,
moduleToken,
token,
classs: BookingAuditTaskConsumer,
depsMap: {
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
auditActorRepository: auditActorRepositoryModuleLoader,
featuresRepository: featuresRepositoryModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule
};
@@ -0,0 +1,24 @@
import { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as taskerModuleLoader } from "@calcom/features/di/shared/services/tasker.service";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
export const bookingAuditProducerServiceModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_PRODUCER_SERVICE;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_PRODUCER_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: bookingAuditProducerServiceModule,
moduleToken,
token,
classs: BookingAuditTaskerProducerService,
depsMap: {
tasker: taskerModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule
};
@@ -0,0 +1,15 @@
import { createContainer } from "@calcom/features/di/di";
import {
type BookingAuditViewerService,
moduleLoader as bookingAuditViewerServiceModule,
} from "./BookingAuditViewerService.module";
const container = createContainer();
export function getBookingAuditViewerService() {
bookingAuditViewerServiceModule.loadModule(container);
return container.get<BookingAuditViewerService>(bookingAuditViewerServiceModule.token);
}
@@ -0,0 +1,29 @@
import { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/service/BookingAuditViewerService";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
export const bookingAuditViewerServiceModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE_MODULE;
export { BookingAuditViewerService }
const loadModule = bindModuleToClassOnToken({
module: bookingAuditViewerServiceModule,
moduleToken,
token,
classs: BookingAuditViewerService,
depsMap: {
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
userRepository: userRepositoryModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule
};
+8 -4
View File
@@ -1,8 +1,12 @@
export const BOOKING_AUDIT_DI_TOKENS = {
BOOKING_AUDIT_SERVICE: Symbol("BookingAuditService"),
BOOKING_AUDIT_SERVICE_MODULE: Symbol("BookingAuditServiceModule"),
BOOKING_AUDIT_VIEWER_SERVICE: Symbol("BookingAuditViewerService"),
BOOKING_AUDIT_VIEWER_SERVICE_MODULE: Symbol("BookingAuditViewerServiceModule"),
BOOKING_AUDIT_PRODUCER_SERVICE: Symbol("BookingAuditProducerService"),
BOOKING_AUDIT_PRODUCER_SERVICE_MODULE: Symbol("BookingAuditProducerServiceModule"),
BOOKING_AUDIT_TASK_CONSUMER: Symbol("BookingAuditTaskConsumer"),
BOOKING_AUDIT_TASK_CONSUMER_MODULE: Symbol("BookingAuditTaskConsumerModule"),
BOOKING_AUDIT_REPOSITORY: Symbol("BookingAuditRepository"),
BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"),
ACTOR_REPOSITORY: Symbol("ActorRepository"),
ACTOR_REPOSITORY_MODULE: Symbol("ActorRepositoryModule"),
AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"),
AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"),
};
@@ -0,0 +1,80 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Accepted Audit Action Service
* Handles ACCEPTED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
status: StringChangeSchema,
});
export class AcceptedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "ACCEPTED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = AcceptedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = AcceptedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof AcceptedAuditActionService.latestFieldsSchema,
typeof AcceptedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: AcceptedAuditActionService.latestFieldsSchema,
storedDataSchema: AcceptedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AcceptedAuditDisplayData {
const { fields } = storedData;
return {
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
}
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AcceptedAuditDisplayData = {
previousStatus: string | null;
newStatus: string | null;
};
@@ -0,0 +1,82 @@
import { z } from "zod";
import { StringArrayChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Attendee Added Audit Action Service
* Handles ATTENDEE_ADDED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
attendees: StringArrayChangeSchema,
});
export class AttendeeAddedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_ADDED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = AttendeeAddedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = AttendeeAddedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof AttendeeAddedAuditActionService.latestFieldsSchema,
typeof AttendeeAddedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: AttendeeAddedAuditActionService.latestFieldsSchema,
storedDataSchema: AttendeeAddedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_added" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeAddedAuditDisplayData {
const { fields } = storedData;
const previousAttendeesSet = new Set(fields.attendees.old ?? []);
const addedAttendees = fields.attendees.new.filter(
(email) => !previousAttendeesSet.has(email)
);
return {
addedAttendees,
};
}
}
export type AttendeeAddedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AttendeeAddedAuditDisplayData = {
addedAttendees: string[];
};
@@ -0,0 +1,80 @@
import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Attendee No-Show Updated Audit Action Service
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
noShowAttendee: BooleanChangeSchema,
});
export class AttendeeNoShowUpdatedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_no_show_updated" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
return {
noShowAttendee: fields.noShowAttendee.new,
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
};
}
}
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AttendeeNoShowUpdatedAuditDisplayData = {
noShowAttendee: boolean;
previousNoShowAttendee: boolean | null;
};
@@ -0,0 +1,85 @@
import { z } from "zod";
import { StringArrayChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Attendee Removed Audit Action Service
* Handles ATTENDEE_REMOVED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
attendees: StringArrayChangeSchema,
});
export class AttendeeRemovedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_REMOVED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = AttendeeRemovedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = AttendeeRemovedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof AttendeeRemovedAuditActionService.latestFieldsSchema,
typeof AttendeeRemovedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: AttendeeRemovedAuditActionService.latestFieldsSchema,
storedDataSchema: AttendeeRemovedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeRemovedAuditDisplayData {
const { fields } = storedData;
// Note: fields.attendees stores the state change (old -> new), not the removed attendees directly
// old = attendees before removal, new = remaining attendees after removal
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
// Compute removed attendees: those in old but not in new (remaining)
const removedAttendees = (fields.attendees.old ?? []).filter(
(email) => !remainingAttendeesSet.has(email)
);
return {
removedAttendees,
};
}
}
export type AttendeeRemovedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AttendeeRemovedAuditDisplayData = {
removedAttendees: string[];
};
@@ -0,0 +1,65 @@
import { z } from "zod";
/**
* Audit Action Service Helper
*
* Provides reusable utility methods for audit action services via composition.
*
* We use composition instead of inheritance for Action services so that services can evolve to v2, v3 independently without polluting a shared base class
*/
export class AuditActionServiceHelper<
TLatestFieldsSchema extends z.ZodTypeAny,
TStoredDataSchema extends z.ZodTypeAny
> {
private readonly latestFieldsSchema: TLatestFieldsSchema;
private readonly latestVersion: number;
private readonly storedDataSchema: TStoredDataSchema;
constructor({
/**
* The schema to validate against latest version
*/
latestFieldsSchema,
latestVersion,
/**
* The schema to validate the stored data that could be of any version
*/
storedDataSchema,
}: {
latestFieldsSchema: TLatestFieldsSchema;
latestVersion: number;
storedDataSchema: TStoredDataSchema;
}) {
this.latestFieldsSchema = latestFieldsSchema;
this.latestVersion = latestVersion;
this.storedDataSchema = storedDataSchema;
}
/**
* Parse input fields with the latest fields schema and wrap with version
*/
getVersionedData(fields: unknown): { version: number; fields: z.infer<TLatestFieldsSchema> } {
const parsed = this.latestFieldsSchema.parse(fields);
return {
version: this.latestVersion,
fields: parsed,
};
}
/**
* Parse stored audit record (includes version wrapper)
* Accepts any version defined in allVersionsDataSchema (for backward compatibility)
*/
parseStored(data: unknown): z.infer<TStoredDataSchema> {
return this.storedDataSchema.parse(data);
}
/**
* Extract version from stored data
*/
getVersion(data: unknown): number {
const parsed = z.object({ version: z.number() }).parse(data);
return parsed.version;
}
}
@@ -0,0 +1,90 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Cancelled Audit Action Service
* Handles CANCELLED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
cancellationReason: StringChangeSchema,
cancelledBy: StringChangeSchema,
status: StringChangeSchema,
});
export class CancelledAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "CANCELLED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = CancelledAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = CancelledAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof CancelledAuditActionService.latestFieldsSchema,
typeof CancelledAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: CancelledAuditActionService.latestFieldsSchema,
storedDataSchema: CancelledAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CancelledAuditDisplayData {
const { fields } = storedData;
return {
cancellationReason: fields.cancellationReason.new ?? null,
previousReason: fields.cancellationReason.old ?? null,
cancelledBy: fields.cancelledBy.new ?? null,
previousCancelledBy: fields.cancelledBy.old ?? null,
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
}
export type CancelledAuditData = z.infer<typeof fieldsSchemaV1>;
export type CancelledAuditDisplayData = {
cancellationReason: string | null;
previousReason: string | null;
cancelledBy: string | null;
previousCancelledBy: string | null;
previousStatus: string | null;
newStatus: string | null;
};
@@ -0,0 +1,85 @@
import { z } from "zod";
import { BookingStatus } from "@calcom/prisma/enums";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Created Audit Action Service
*
* Note: CREATED action captures initial state, so it doesn't use { old, new } pattern
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
startTime: z.number(),
endTime: z.number(),
status: z.nativeEnum(BookingStatus),
});
export class CreatedAuditActionService implements IAuditActionService<
typeof fieldsSchemaV1,
typeof fieldsSchemaV1
> {
readonly VERSION = 1;
public static readonly TYPE = "CREATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = CreatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema,
storedDataSchema: CreatedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.created" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CreatedAuditDisplayData {
const { fields } = storedData;
return {
startTime: new Date(fields.startTime).toISOString(),
endTime: new Date(fields.endTime).toISOString(),
status: fields.status,
};
}
}
export type CreatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type CreatedAuditDisplayData = {
startTime: string;
endTime: string;
status: BookingStatus;
};
@@ -0,0 +1,80 @@
import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Host No-Show Updated Audit Action Service
* Handles HOST_NO_SHOW_UPDATED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
noShowHost: BooleanChangeSchema,
});
export class HostNoShowUpdatedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema,
typeof HostNoShowUpdatedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema,
storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.host_no_show_updated" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): HostNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
return {
noShowHost: fields.noShowHost.new,
previousNoShowHost: fields.noShowHost.old ?? null,
};
}
}
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type HostNoShowUpdatedAuditDisplayData = {
noShowHost: boolean;
previousNoShowHost: boolean | null;
};
@@ -0,0 +1,101 @@
import { z } from "zod";
/**
* Represents a component that can be interpolated into translations
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
*/
export type TranslationComponent = {
type: "link";
href: string;
};
/**
* Represents a translation key with optional interpolation params and components
* Used for dynamic display titles that need to be translated with context
* Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking</1>")
*/
export type TranslationWithParams = {
key: string;
params?: Record<string, string | number>;
components?: TranslationComponent[];
};
/**
* Interface for Audit Action Services
*
* Defines the contract that all audit action services must implement.
* Uses composition with AuditActionServiceHelper to provide common functionality
* while maintaining type safety and flexibility for versioned schemas.
*
* @template TLatestFieldsSchema - The Zod schema type for the latest version's audit fields (write operations)
* @template TStoredFieldsSchema - The Zod schema type for all supported versions' audit fields (read operations, union type)
*/
export interface IAuditActionService<
TLatestFieldsSchema extends z.ZodTypeAny,
TStoredFieldsSchema extends z.ZodTypeAny
> {
/**
* Current version number for this action type
*/
readonly VERSION: number;
/**
* Parse given fields against latest schema and wrap with version
* @param fields - Raw input fields (just the audit fields)
* @returns Parsed data with version wrapper { version, fields }
*/
getVersionedData(fields: unknown): { version: number; fields: z.infer<TLatestFieldsSchema> };
/**
* Parse stored audit record (includes version wrapper)
* Accepts data from ANY supported version (not just latest) for backward compatibility
* @param data - Stored data from database (can be any version)
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
*/
parseStored(data: unknown): { version: number; fields: z.infer<TStoredFieldsSchema> };
/**
* Extract version number from stored data
* @param data - Stored data from database
* @returns Version number
*/
getVersion(data: unknown): number;
/**
* Get flattened JSON data for display (fields only, no version wrapper)
* Optional - implement only if custom display formatting is needed
* @param storedData - Parsed stored data { version, fields }
* @returns The fields object without version wrapper and we decide what fields to show to the client
*/
getDisplayJson?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): unknown;
/**
* Get the display title for the audit action
* Returns a translation key with optional interpolation params for dynamic titles
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
* @param storedData - Parsed stored data { version, fields }
* @returns Translation key with optional interpolation params
*/
getDisplayTitle(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): Promise<TranslationWithParams>;
/**
* Migrate old version data to latest version
*
* Required method that validates and migrates data to latest schema version.
* For V1-only actions, simply validates and returns with isMigrated=false.
* For multi-version actions, checks version and transforms if needed.
*
* @param data - Data from task payload (any supported version)
* @returns Migration result with status and latest data
*/
migrateToLatest(data: unknown): {
/**
* True if migration was performed, false if already latest
*/
isMigrated: boolean;
/**
* Always set, either migrated or original
*/
latestData: z.infer<TLatestFieldsSchema>;
};
}
@@ -0,0 +1,79 @@
import { z } from "zod";
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import { getTranslation } from "@calcom/lib/server/i18n";
/**
* Location Changed Audit Action Service
* Handles LOCATION_CHANGED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
location: StringChangeSchema,
});
export class LocationChangedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "LOCATION_CHANGED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = LocationChangedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = LocationChangedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof LocationChangedAuditActionService.latestFieldsSchema,
typeof LocationChangedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: LocationChangedAuditActionService.latestFieldsSchema,
storedDataSchema: LocationChangedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
const { fields } = storedData;
// TODO: Ideally we want to translate the location label to the user's locale
// We currently don't accept requesting user's translate fn here, fix it later.
const t = await getTranslation("en", "common");
const fromLocation = getHumanReadableLocationValue(fields.location.old, t);
const toLocation = getHumanReadableLocationValue(fields.location.new, t);
return {
key: "booking_audit_action.location_changed_from_to",
params: { fromLocation, toLocation },
};
}
}
export type LocationChangedAuditData = z.infer<typeof fieldsSchemaV1>;
@@ -0,0 +1,91 @@
import { z } from "zod";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Reassignment Audit Action Service
* Handles REASSIGNMENT action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
assignedToId: NumberChangeSchema,
assignedById: NumberChangeSchema,
reassignmentReason: StringChangeSchema,
userPrimaryEmail: StringChangeSchema.optional(),
title: StringChangeSchema.optional(),
});
export class ReassignmentAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "REASSIGNMENT" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = ReassignmentAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof ReassignmentAuditActionService.latestFieldsSchema,
typeof ReassignmentAuditActionService.storedDataSchema
>;
constructor(private userRepository: UserRepository) {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema,
storedDataSchema: ReassignmentAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
const { fields } = storedData;
const user = await this.userRepository.findById({ id: fields.assignedToId.new });
const reassignedToName = user?.name || "Unknown";
return {
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: reassignedToName },
};
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): ReassignmentAuditDisplayData {
const { fields } = storedData;
return {
newAssignedToId: fields.assignedToId.new,
reassignmentReason: fields.reassignmentReason.new ?? null,
};
}
}
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
export type ReassignmentAuditDisplayData = {
newAssignedToId: number;
reassignmentReason: string | null;
};
@@ -0,0 +1,85 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Rejected Audit Action Service
* Handles REJECTED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
rejectionReason: StringChangeSchema,
status: StringChangeSchema,
});
export class RejectedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "REJECTED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = RejectedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = RejectedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof RejectedAuditActionService.latestFieldsSchema,
typeof RejectedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: RejectedAuditActionService.latestFieldsSchema,
storedDataSchema: RejectedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.rejected" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RejectedAuditDisplayData {
const { fields } = storedData;
return {
rejectionReason: fields.rejectionReason.new ?? null,
previousReason: fields.rejectionReason.old ?? null,
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
}
export type RejectedAuditData = z.infer<typeof fieldsSchemaV1>;
export type RejectedAuditDisplayData = {
rejectionReason: string | null;
previousReason: string | null;
previousStatus: string | null;
newStatus: string | null;
};
@@ -0,0 +1,82 @@
import { z } from "zod";
import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Reschedule Requested Audit Action Service
* Handles RESCHEDULE_REQUESTED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
cancellationReason: StringChangeSchema,
cancelledBy: StringChangeSchema,
rescheduled: BooleanChangeSchema.optional(),
});
export class RescheduleRequestedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "RESCHEDULE_REQUESTED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof RescheduleRequestedAuditActionService.latestFieldsSchema,
typeof RescheduleRequestedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema,
storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
return { key: "booking_audit_action.reschedule_requested" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduleRequestedAuditDisplayData {
const { fields } = storedData;
return {
reason: fields.cancellationReason.new ?? null,
requestedBy: fields.cancelledBy.new ?? null,
};
}
}
export type RescheduleRequestedAuditData = z.infer<typeof fieldsSchemaV1>;
export type RescheduleRequestedAuditDisplayData = {
reason: string | null;
requestedBy: string | null;
};
@@ -0,0 +1,112 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Rescheduled Audit Action Service
* Handles RESCHEDULED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
startTime: StringChangeSchema,
endTime: StringChangeSchema,
rescheduledToUid: StringChangeSchema,
});
export class RescheduledAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "RESCHEDULED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = RescheduledAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = RescheduledAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof RescheduledAuditActionService.latestFieldsSchema,
typeof RescheduledAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: RescheduledAuditActionService.latestFieldsSchema,
storedDataSchema: RescheduledAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
const rescheduledToUid = storedData.fields.rescheduledToUid.new;
return {
key: "booking_audit_action.rescheduled",
components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined,
};
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduledAuditDisplayData {
const { fields } = storedData;
return {
previousStartTime: fields.startTime.old ?? null,
newStartTime: fields.startTime.new ?? null,
previousEndTime: fields.endTime.old ?? null,
newEndTime: fields.endTime.new ?? null,
rescheduledToUid: fields.rescheduledToUid.new ?? null,
};
}
/**
* Finds the rescheduled log that created a specific booking
* by matching the rescheduledToUid field with the target booking UID
* @param rescheduledLogs - Array of rescheduled audit logs to search through
* @param rescheduledToBookingUid - The UID of the booking that was created from the reschedule
* @returns The matching log or null if not found
*/
getMatchingLog<T extends { data: unknown }>({
rescheduledLogs,
rescheduledToBookingUid,
}: {
rescheduledLogs: T[];
rescheduledToBookingUid: string;
}): T | null {
return rescheduledLogs.find((log) => {
const parsedData = this.parseStored(log.data);
return parsedData.fields.rescheduledToUid.new === rescheduledToBookingUid;
}) ?? null;
}
}
export type RescheduledAuditData = z.infer<typeof fieldsSchemaV1>;
export type RescheduledAuditDisplayData = {
previousStartTime: string | null;
newStartTime: string | null;
previousEndTime: string | null;
newEndTime: string | null;
rescheduledToUid: string | null;
};
@@ -0,0 +1,27 @@
import { z } from "zod";
/**
* Common change schemas for audit data
* These represent old -> new value transitions
*/
export const StringChangeSchema = z.object({
old: z.string().nullable(),
new: z.string().nullable(),
});
export const BooleanChangeSchema = z.object({
old: z.boolean().nullable(),
new: z.boolean(),
});
export const StringArrayChangeSchema = z.object({
old: z.array(z.string()).nullable(),
new: z.array(z.string()),
});
export const NumberChangeSchema = z.object({
old: z.number().nullable(),
new: z.number(),
});
@@ -13,9 +13,8 @@ type AuditActor = {
export interface IAuditActorRepository {
findByUserUuid(userUuid: string): Promise<AuditActor | null>;
findSystemActorOrThrow(): Promise<AuditActor>;
// TODO: To be implemented in followup PR
// upsertUserActor(userUuid: string): Promise<AuditActor>;
// upsertGuestActor(email: string, name?: string, phone?: string): Promise<AuditActor>;
// findByAttendeeId(attendeeId: number): Promise<AuditActor | null>;
createIfNotExistsUserActor(params: { userUuid: string }): Promise<AuditActor>;
createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null): Promise<AuditActor>;
findByAttendeeId(attendeeId: number): Promise<AuditActor | null>;
}
@@ -1,6 +1,15 @@
import type { JsonValue } from "@calcom/types/Json";
import type { AuditActorType } from "./IAuditActorRepository";
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
/**
* Booking audit actions track changes to bookings throughout their lifecycle.
*
* Note: PENDING and AWAITING_HOST represent initial booking states, not transitions.
* They are reserved in the enum for potential future use but should not appear in audit logs.
* Use the CREATED action to capture initial booking status instead.
*/
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED"
export type BookingAuditCreateInput = {
bookingUid: string;
@@ -23,10 +32,27 @@ type BookingAudit = {
data: JsonValue;
}
export type BookingAuditWithActor = BookingAudit & {
actor: {
id: string;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
name: string | null;
createdAt: Date;
};
}
export interface IBookingAuditRepository {
/**
* Creates a new booking audit record
*/
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
}
/**
* Retrieves all audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
* @returns Array of audit logs with actor information, ordered by timestamp DESC
*/
findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
}
@@ -25,5 +25,87 @@ export class PrismaAuditActorRepository implements IAuditActorRepository {
return actor;
}
async createIfNotExistsUserActor(params: { userUuid: string }) {
return this.deps.prismaClient.auditActor.upsert({
where: { userUuid: params.userUuid },
create: {
type: "USER",
userUuid: params.userUuid,
},
update: {},
});
}
async createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null) {
const normalizedEmail = email && email.trim() !== "" ? email : null;
const normalizedName = name && name.trim() !== "" ? name : null;
const normalizedPhone = phone && phone.trim() !== "" ? phone : null;
// If all fields are null, we can't use upsert (no unique constraint), so just create a new record
if (!normalizedEmail && !normalizedPhone) {
return this.deps.prismaClient.auditActor.create({
data: {
type: "GUEST",
email: null,
name: normalizedName,
phone: null,
},
});
}
// First try to find by email if email exists
if (normalizedEmail) {
const existingByEmail = await this.deps.prismaClient.auditActor.findUnique({
where: { email: normalizedEmail },
});
if (existingByEmail) {
// Update existing record found by email
return this.deps.prismaClient.auditActor.update({
where: { email: normalizedEmail },
data: {
name: normalizedName ?? undefined,
phone: normalizedPhone ?? undefined,
},
});
}
}
// If not found by email and phone exists, try to find by phone
if (normalizedPhone) {
const existingByPhone = await this.deps.prismaClient.auditActor.findUnique({
where: { phone: normalizedPhone },
});
if (existingByPhone) {
// Update existing record found by phone
return this.deps.prismaClient.auditActor.update({
where: { phone: normalizedPhone },
data: {
email: normalizedEmail ?? undefined,
name: normalizedName ?? undefined,
},
});
}
}
// Not found by either email or phone, create new record
return this.deps.prismaClient.auditActor.create({
data: {
type: "GUEST",
email: normalizedEmail,
name: normalizedName,
phone: normalizedPhone,
},
});
}
async findByAttendeeId(attendeeId: number) {
return this.deps.prismaClient.auditActor.findUnique({
where: { attendeeId },
});
}
}
@@ -1,10 +1,24 @@
import type { PrismaClient } from "@calcom/prisma";
import type { IBookingAuditRepository, BookingAuditCreateInput } from "./IBookingAuditRepository";
import type { IBookingAuditRepository, BookingAuditCreateInput, BookingAuditWithActor } from "./IBookingAuditRepository";
type Dependencies = {
prismaClient: PrismaClient;
}
/**
* Safe actor fields to expose in audit logs
* Excludes PII fields like email and phone that aren't needed for display
*/
const safeActorSelect = {
id: true,
type: true,
userUuid: true,
attendeeId: true,
name: true,
createdAt: true,
} as const;
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
constructor(private readonly deps: Dependencies) { }
@@ -20,5 +34,21 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
},
});
}
async findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
return this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
},
include: {
actor: {
select: safeActorSelect,
},
},
orderBy: {
timestamp: "desc",
},
});
}
}
@@ -0,0 +1,34 @@
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
/**
* BookingAuditProducerService Interface
*
* Producer abstraction for queueing booking audit tasks.
* Allows for multiple implementations (e.g., Tasker, Trigger.dev).
*
* Implementations:
* - BookingAuditTaskerProducerService: Uses Tasker for local/background jobs
* - BookingAuditTriggerProducerService: (Future) Uses Trigger.dev for cloud jobs
*/
export interface BookingAuditProducerService {
/**
* Queue Audit - Push audit task for async processing
*
* Single method that handles all audit types using discriminated union.
* TypeScript ensures type safety between action and data.
*
* @param bookingUid - The booking UID
* @param actor - The actor performing the action
* @param organizationId - The organization ID (null for non-org bookings)
* @param actionData - Discriminated union of action type and its data
*
* @example
* await bookingAuditProducerService.queueAudit(bookingUid, actor, organizationId, {
* action: "CREATED",
* data: { startTime, endTime, status }
* });
*/
queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise<void>;
}

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