Compare commits

..
96 Commits
Author SHA1 Message Date
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
Alex van Andel 46b88a3c81 chore: release v5.9.9
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-05 10:55:08 +00:00
sean-brydon c97f1928bb fixes refresh issue (#25616) 2025-12-05 09:37:35 +00:00
dea6ec5003 perf: handleChildrenEventTypes transaction (#25602)
* perf: handleChildrenEventTypes transaction

* fixup! perf: handleChildrenEventTypes transaction

* fix: e2e tests

* test: update handleChildrenEventTypes tests for createMany API

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

* fix: tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-05 08:35:29 +00:00
Rouh 8cace7f7a0 fix (a11y): Add aria-label to progressToast close button (#25618) 2025-12-05 04:40:12 -03:00
Hariom BalharaandDevin AI b80c0692dc fix: improve Vercel domain 'forbidden' error message and add schema fields (#25520)
* fix: improve Vercel domain 'forbidden' error message

The 'forbidden' error code from Vercel is a generic permission denial,
not specifically 'domain owned by another team'. It can occur for various
reasons: wrong teamId, token lacking project access, account-level
restrictions, or domain ownership by another team.

Updated the error message to be more accurate and changed the HTTP status
code from 400 to 403 to better reflect the permission error nature.

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

* fix: add message and invalidToken fields to Vercel error response schema

This allows better logging of Vercel API error responses, especially when
tokens become invalid. The new fields help with debugging permission issues.

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

* fix: revert status code to 400 for Vercel forbidden errors

Keep the status code at 400 to avoid potential downstream side effects
while still using the improved error message and schema fields.

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

* fix: use .nullish() instead of .optional() for message and invalidToken fields

.nullish() handles both null and undefined, which is safer for API responses.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-05 05:26:42 +00:00
Kartik LabhshetwarandDhairyashil Shinde 44291be7b7 fix: show rescheduled badge on mobile view in bookings list (#25542)
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-05 04:00:03 +00:00
Kartik 54440cf954 fix: weekly view slots not clickable with overlay calendar (#25484) 2025-12-04 21:11:45 +00:00
Dhairyashil Shinde 43315108a2 refactor: remove cal workflow trigger message from workflow templates (#25539)
* refactor: remove cal workflow trigger message

* remove from rating template
2025-12-04 20:45:38 +00:00
Emmanuel IsenahandDhairyashil Shinde 311b6e071d feat: support hideEventTypeDetails query param on booking pages (#25565)
* fix: lint error

* feat: support `hideEventTypeDetails` via query param

Allow the event type details to be hidden based on the
hideEventTypeDetails query parameter when not in embed mode

* feat: add hideEventTypeDetails prop to EventMeta

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-04 20:26:08 +00:00
Kartik 308c7fa12b fix: correct typos and HTML encoding in org invite emails (#25551)
* fix: correct typos and HTML encoding in org invite emails

* removed interpolation: { escapeValue: false }

* added interpolation: { escapeValue: false }
2025-12-04 19:53:44 +00:00
Anshumancanrock 51cf73218b fix: Radio buttons not filled in (CSS background-color) (#25599)
* Radio buttons not filled in (CSS background-color)

* fix: radio buttons not filled when selected in light mode
2025-12-04 19:49:02 +00:00
Joe Au-YeungandDevin AI dc44825ce5 fix: prevent overwriting existing event type values with undefined (#25624)
When updating an event type, fields like autoTranslateDescriptionEnabled
and disableGuests were being overwritten with false/default values even
when they weren't explicitly provided in the update request.

Changes:
- autoTranslateDescriptionEnabled: Only set when explicitly provided
  (not undefined) to avoid overwriting existing true values with false
- disableGuests: Only set when bookingFields is explicitly provided,
  since it's derived from the guests field in bookingFields

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-04 19:18:45 +00:00
Kartik Labhshetwar 908128ca5d style: improve dropdown option spacing and hover separation (#25605)
* feat(ui): improve dropdown option spacing and hover separation

* style: add flex column layout and spacing to timezone select scrollbar
2025-12-05 00:47:21 +05:30
Dhairyashil ShindeandPallav b12f14c538 feat: auto scroll sidebar to active team on navigation (#25555)
* feat: auto-scroll sidebar to active team on navigation

When navigating to team settings via "Edit team" from the teams list,
the sidebar now scrolls to show the selected team and expands its section.

- Extract team ID from URL path (/settings/teams/{id}/...)
- Use existing aria-controls attribute to find team element
- Scroll with smooth behavior centered in viewport

* revert formatting changes by prettier

---------

Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-05 00:46:18 +05:30
Anik Dhabal Babu f03e6075a2 fix: unable to confirm/reject booking from email (#25614)
* fix: unable to confirm/reject booking

* fix lint

* Refactor booking confirmation and rejection tests
2025-12-04 23:17:17 +05:30
Peer Richelsen a8aac6dc3a added eas (#25619) 2025-12-04 14:36:38 -03:00
Anik Dhabal Babu 1594567fd5 fix: issue with workflow meetingUrl (#25620) 2025-12-04 16:46:36 +00:00
Volnei Munhoz dde6e7d140 chore: bump nextjs version (#25592)
* bump nextjs version

* bump nextjs version for api v1
2025-12-04 10:46:34 -03:00
Eesh Midha d8f36d0665 fix: enable date picker taps on iOS by setting modal={false} (#25590) 2025-12-04 13:33:43 +00:00
Abhishek 9362253bfb fix: vertical tab spacing (#25612) 2025-12-04 12:56:05 +00:00
sean-brydonandEunjae Lee c91da96956 fix: reset store on getting started (#25607)
* reset store

* Update apps/web/modules/onboarding/getting-started/onboarding-view.tsx

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-12-04 11:23:52 +00:00
Abhishek dbb33b9632 fix(ui): improve the alignment of api keys code (#25570)
fix: api key horizontally centered
2025-12-04 10:54:48 +00:00
Anik Dhabal Babu ead0691ea3 fix: refactored few handler and repository (#25567)
* update

* update

* refactor
2025-12-04 11:43:29 +02:00
Abir Roy e4a6b97017 fix(routing-forms): correct variable encoding and URL construction in Routing Forms Event Redirect Custom URLs (#25499)
* fix: change slugification to uri encoding

* fix: add conditional joiner symbol between event type redirect url and all other search params

* fix: update integration test to expect encoded variable

* test: add tests for query parameters joiner logic
2025-12-04 08:59:22 +00:00
sean-brydon 98b862c048 fix: form issues in QA for onboarding (#25581)
## What does this PR do?
Fixes the issue where form state wasnt corectly recorded in QA

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2025-12-04 08:58:00 +00:00
Hariom BalharaandDevin AI 78c411621e feat(embed-core): add public close() API for modal-based embeds (#24746)
* feat(embed-core): add public close() API for modal-based embeds

Add a proper public API method close() to the CalApi class that allows
users to programmatically close modal-based embeds (ModalBox and FloatingButton).

This replaces the need for the undocumented hack:
  cal.instance.actionManager.fire('__closeIframe')

The new API is cleaner and more intuitive:
  cal('close')

Key features:
- Works for modal-based embeds only (ModalBox and FloatingButton)
- Throws a clear error if called on inline embeds
- Well-documented with JSDoc comments explaining usage and limitations

Example usage:
  cal('on', {
    action: 'bookingSuccessful',
    callback: () => {
      cal('close');
    }
  });

Also fixed pre-existing lint issues: unused variable in catch block and
eslint-disable comment for missing rule definition.

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

* refactor(embed-core): rename close() to closeModal() for clarity

Rename the close() method to closeModal() to make it immediately clear
from the API name itself that this method is for modal-based embeds only.

This makes the API more self-documenting:
- Before: cal('close')
- After: cal('closeModal')

Updated:
- Method name from close() to closeModal()
- JSDoc example to use cal('closeModal')
- Error message to reference closeModal()

The TypeScript types automatically recognize the new action name through
the SingleInstructionMap type system.

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

* cleanup

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-04 05:48:11 -03:00
Lauris Skraucis a1e1bf8b76 chore: add atoms changeset for tailwind v4 (#25577) 2025-12-04 08:42:58 +00:00
d00cf329e8 fix: workflow reminder send to all RR hosts. (#25597)
* fix: wrkflow reminder issue

* Update EmailWorkflowService.ts

* update

* Refactor team member email extraction for clarity

* add tests

* Undo changes in EmailWorkflowService

* Get team members assigned to the booking

* fix: Update test to match EmailWorkflowService behavior for ROUND_ROBIN

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

* fix: Update CalendarEventBuilder tests to include host emails in attendees

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

* Apply suggestion from @cubic-dev-ai[bot]

Filter organizer our of `hostsToInclude`

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

* Only include organizer and host destination calendar

* Type fix

* Type fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-04 09:21:48 +02:00
Udit Takkar 843329ccce feat: distributed tracing - 3 (#25092)
* feat: distributed tracing 2

* feat: distributed tracing 2

* refactor: feedback

* refactor: feedback

* fix: type error

* fix: trpc error

* feat: distributed tracing - 3

* chore: translation

* refactor: improvements

* fix: feedback

* chore: remove
2025-12-04 05:51:26 +00:00
741144bff2 fix: correct busy slot title for ics feed integration troubleshoot (#23929)
* fix: correct busy slot title for ics feed integration troubleshoot

* Prevent 'as' call by wrapping getFirstPropertyValue in string

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-12-04 05:49:34 +00:00
Udit TakkarandMorgan a11f7e6510 feat: add session endpoint and booking access pbac (#24637)
* feat: add session endpoint

* fix: remove participant id

* refactor; feedback

* fix: e2e test

* refactor: add pbac

* refactor: use constant

* tests: add booking tests

* fix: conflicts

* chore: test

* fix: tests

* fix: tests

* chore: remove un necessary

* chore: docs

* fix: desc

* fix: booking-pbac gurd

* fix: booking-pbac gurd

* test: add unit tests

* fix: remove legacy pbac

* fix: chore remove

* fix: update tests

* test: add more test

* test: move it to new file

* chore: comment auth

* chore: add message

* chore: add message

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-12-04 05:45:17 +00:00
Anik Dhabal Babu 3a8bf7014c fix: add all the attendees to ics (#25601) 2025-12-04 05:38:38 +00:00
6f02fbe13b fix: expand Intercom allowed URLs and prevent double-prefixing (#25181)
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-04 05:36:24 +00:00
af3f44a7f5 feat: add data region dropdown to signup page (#25127)
* feat: add data region dropdown to signup page

Add a dropdown to the signup page that allows users to select their data region (United States or European Union). When a region is selected, the user is navigated to the appropriate domain (app.cal.com or app.cal.eu).

- Add SelectField component to signup page below the description text
- Detect current domain and set appropriate default value
- Navigate to correct domain when selection changes
- Add translation strings for data_region, united_states, and european_union

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

* added localhost

* fix: improve data region dropdown - SSR hydration, query params, and self-hosted protection (#25463)

* fix/remove-eu-us-dropdown-from-self-hosted-signup

* fix: remove localhost bypass and fix SSR hydration for data region dropdown

- Remove localhost bypass from IS_CALCOM check to prevent self-hosted instances from seeing Cal.com-specific dropdown
- Fix SSR hydration mismatch by using WEBAPP_URL instead of window.location for region detection
- Fix hostname replacement to preserve query parameters by modifying hostname directly instead of entire URL string

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2025-12-04 05:34:24 +00:00
Joe Au-YeungandAnik Dhabal Babu 0605572969 fix: Log detailed error in getConnectedCalendar (#25338)
* Log error

* Reverse sent error message changes

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-03 19:51:51 +00:00
AbhishekandAnik Dhabal Babu 5ddfbcf225 fix: date picker content render issue (#25584)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-03 19:46:28 +00:00
Volnei Munhoz 2fc1078158 fix companion oidc url (#25572) 2025-12-03 19:38:41 +00:00
sean-brydon ad416ac9df fix error spam (#25591) 2025-12-03 19:36:05 +00:00
Anik Dhabal Babu 2a7439b58d fix: e2e flake (#25578) 2025-12-03 16:07:27 -03:00
Anik Dhabal Babu 8aaa84142b fix: unable to edit member as a team owner/admin (#25587)
* fix: unaable to edit member as owner/admin

* Clean up comments in EditMemberSheet.test.tsx

Removed comments explaining the fix for permissions prop.

* revert
2025-12-03 18:14:35 +00:00
Udit Takkar 059ca2503d fix: prevent clicking outside (#25588) 2025-12-03 17:57:23 +00:00
Udit Takkar 35d6c41fff feat: Disable booking emails to guests (#25217)
* feat: disable SMS org setting

* chore: undo

* fix: cal evnet

* feat: add more settings

* tests: add email manager unit tests

* fix: update

* fix: type error

* fix: test

* refactor: UI

* refactor: UI

* perf: fetch only once

* perf: fetch only once

* chore: use org

* fix: test

* test: add unit tests

* refactor: email manager

* fix: add confirmation dialog for individual checkbox

* fix: sms

* chore: common.json
2025-12-03 17:21:31 +00:00
Eunjae LeeandDevin AI 1c3ced5b70 fix: simplify date range picker to Airbnb-style selection (#25386)
* feat: simplify date range picker to Airbnb-style selection

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

* feat: apply Airbnb-style selection to allowPastDates branch

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

* refactor: extract date range selection logic and add unit tests

- Extract date range selection logic into pure function (dateRangeLogic.ts)
- Remove unused allowPastDates parameter from selection logic
- Add comprehensive unit tests (11 test cases covering all scenarios)
- Simplify DateRangePicker component (30+ lines -> 5 lines)
- Improve separation of concerns: allowPastDates only controls calendar date restrictions

* style update

* feat: add hover highlighting for date range selection

When a start date is selected and user hovers over other dates,
the potential range between start and hovered date now shows
bg-emphasis background for better visual feedback.

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

* memoize hovering range and update styles

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-03 14:52:25 +00:00
88ce7868e8 feat: companion expo and chrome (#25022)
* feat: companion expo and chrome

* fix: api-v2-fetch-bookings (#25023)

* migrated to app expo router

* feat(companion): added availability screen and fix event type fetch (#25025)

* fix(companion): add host check in bookings (#25036)

* using liquid glass bottom nav experimental

* added action sheet

* added icons to event type tabs

* added action bar for event-type details

* long press for edit event types

* added limits

* added more limits

* added more limits

* added username to event types

* feat(companion): config to set and show all the available meeting options a user has stalled (#25081)

* feat: Add tailwind support using nativewind. (#25028)

* init nativewind

* replace styles with tailwind classes

* changes

* feat(companion): all locations options, icons, and apply nativewind to remaining files (#25143)

* feat: all locations options and icons and apply nativwind to reamaining files

* icon bug fix

* cal video icon

* other location frontend

* other location api

* address cubics comment about correctly detects SVGs

* feat(companion): edit availability (#25149)

* feat: companion-edit-availability

* date override and use correct config for patch api endpoint

* Android Alert supports a maximum of 3 buttons

* feat: companion-ui-release-1 (#25150)

* feat(companion): Add event type & schedule creation flows with API expansion (#25187)

* feat(companion): event schedule creation and apis

* profile section

* limits, advanced, recurring tab

* Clean Folder Structure

* addressed cubics comments

* fix cubiics comments

* replaced text with cal.com svg logo

* added correct more button for event-types

* added copy link button

* added extension popup

* added chrome extension sidebar

* achieved liquid glass buttons

* nit

* more progress

* fixed ios bug, added icon

* added cal icon to gmail

* nit

* nit

* added dialog for one-off meeting

* added one-time dialog

* feat(companion): gmail plugin (#25327)

* Update Cal.com Companion to version 1.7.0 with enhanced background and content scripts. The background script now handles fetching event types from the Cal.com API and improved error handling. The content script has been updated to manage sidebar visibility and respond to messages more effectively. Removed deprecated dev background script and updated manifest permissions for better functionality.

* chore: update companion .gitignore to exclude build outputs and env files

* feat: insert Cal.com event link directly at cursor position in Gmail compose

- Add insertTextAtCursor function to insert booking link where user is typing
- Automatically positions cursor after inserted link for seamless typing
- Fallback to clipboard copy if compose field not found
- Improved UX: no need to manually paste the link

* feat(companion): improve notification UI and add event types caching

- Update notification to Cal.com brand style (black bg, check icon, smooth fade)
- Change notification text to 'Link inserted' for subtlety
- Move notification to bottom-right position (matching Cal.com toasts)
- Add event types caching (5min duration) to reduce API calls
- Cache automatically refreshes on page reload
- Fix TypeScript error with composeBody.focus() type assertion

* Notification toast  border

* conditional description

* conditional description

* feat(companion): Enhance UI with action buttons, tooltips, and Gmail extension improvements (#25353)

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat(companion): one-off links (#25355)

* added one off prototype

* resolve merge conflicts

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>

* fixed sidebar iframe

* fix: enable pointer events on sidebar while maintaining click-through on transparent area

- Added pointerEvents: 'auto' to sidebar View component in _layout.tsx
- Changed iframe width from 100% to 400px to match sidebar width
- Changed iframe pointerEvents from 'none' to 'auto' to enable clicking
- This allows sidebar items to be clickable while transparent area remains click-through

* feat(companion): center all modals on full screen in browser extension (#25380)

* fix: companion-full-width-iframe

* center the popups on extension view

* feat(companion): center all modals on full screen in browser extension

- Created FullScreenModal component that expands iframe to full width
- Updated content script to handle instant iframe expansion/collapse
- Converted 17 modals across 6 files to use FullScreenModal
- All modals now appear centered on full browser viewport
- Removed animations for instant modal appearance
- Mobile app behavior remains unchanged

Files updated:
- companion/components/FullScreenModal.tsx (new)
- companion/extension/entrypoints/content.ts
- companion/app/(tabs)/event-types.tsx (5 modals)
- companion/app/(tabs)/bookings.tsx (2 modals)
- companion/app/(tabs)/availability.tsx (3 modals)
- companion/app/booking-detail.tsx (2 modals)
- companion/app/availability-detail.tsx (4 modals)
- companion/components/Header.tsx (1 modal)

* security(companion): validate postMessage origin in content script

Add origin and source validation to postMessage listener to prevent
malicious scripts on host pages from manipulating the companion iframe.

- Verify event.source matches iframe.contentWindow
- Verify event.origin matches iframe origin (localhost:8081)
- Reject all unauthorized messages before processing

This prevents external scripts from:
- Forcing full-screen overlays
- Intercepting pointer events
- Manipulating the companion UI

* fix(companion): center 'Add new event type' modal on full screen

Replace KeyboardAvoidingView with TouchableOpacity pattern in the
create event type modal to ensure it appears centered on the full
screen like all other modals.

This was the last remaining modal that wasn't properly centered.

* fix(companion): resolve modal transition timing issue

Fixed the 'Add new event type' modal not centering properly by:

- Added 100ms delay when transitioning between modals
- Prevents conflicting expand/collapse messages
- Removed redundant postMessage calls from modal handlers
- Added max-height constraint to modal content
- Cleaned up debug console logs

The issue was that when clicking 'New Event Type' from the 'New' menu,
both modals were transitioning simultaneously, causing the create modal
to collapse immediately. The delay ensures proper sequencing.

All 17 modals now work correctly and appear centered on full screen.

* added working date range

* removed all of one-off event code

* nit

* feat(companion): Suggest `Cal.com` links and Embed using Gemini Ai in Gmail (#25406)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* add companion build on CI

* feat: oauth companion (#25526)

* first implemenation of oauth

* fix logout for web

* revert app name

* remove isUsingOAuth

* clean up log out button

* remove logs

* clean up authcontext

* simplify code generation

* code clean up

* clean up oauthService.ts

* oauth browser extension

* add redirect uri to .env.example

* remove api key support

* clean up _layout file

* add identity

* fix + clean up

* improvements to oauth flow

* code clean up

* remove comments

* clean up comments

* add log out dialog

* add confirm modal

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Abhijeet Singh <asingh9829@gmail.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-12-03 08:46:04 -03:00
ba3838ec0f chore: docs for BookerEmbed atom (#25541)
* chore: docs for BookerEmbed atom

* chore: PR feedback

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

* Update docs/platform/atoms/booker-embed.mdx

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-12-03 11:39:09 +00:00
sean-brydon f87d4de546 fix: UI nits in QA verison of onboarding v3 (#25532)
* fix create button

* fix subdomain prefix in orgs details view

* remove redundant text

* fix prefix on link fields

* brand mode colours

* Fix banner positioning

* remove app descrtipion plus add mr to account for button

* fix gaps

* improve free plan welcome modal
2025-12-03 09:57:01 +00:00
Alex van Andel fd6c6e6419 chore: release v5.9.8
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-03 09:25:02 +00:00
Alex van AndelandDevin AI 63d1361a7a fix: simplify credentials provider authorization flow (#25563)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-03 09:21:20 +00:00
Udit Takkar cbcd769f88 docs: add automatic no show updated docs (#25507) 2025-12-03 06:35:54 +00:00
Anik Dhabal Babu a2185d9ee0 fix: sms and whatsapp reminder issue with seated event (#25294)
* fix: sms reminder issue with seated event

* fix seated event and workflow issue

* fix whatsapp reminder

* Refactor imports in reminderScheduler.ts

* use repository
2025-12-03 04:12:38 +00:00
Anik Dhabal Babu 7a66add217 Prevent navigation for disabled booking actions (#25543) 2025-12-03 04:10:31 +00:00
Morgan 68b93385eb refactor: platform billing controller auth guards (#25559)
* refactor: platform billing controller auth guards

* fixup! refactor: platform billing controller auth guards
2025-12-02 20:38:29 +00:00
Alex van Andel e2aa6361d5 fix: config is statically analyzed, failing on build (#25558) 2025-12-02 18:38:29 +00:00
d15281160f fix: preserve seatsPerTimeSlot during partial event type updates (#25450)
* fix: preserve seatsPerTimeSlot during partial event type updates

When doing a partial update on the PATCH /v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId} endpoint, providing a partial body would reset seatsPerTimeSlot back to null because the transformSeatsApiToInternal function was always called even when seats was undefined.

This fix ensures that the seat options are only transformed when the seats field is explicitly provided in the update request, preserving existing values during partial updates.

Also adds e2e test to verify partial updates preserve seatsPerTimeSlot.

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

* fix: handle TypeScript union type narrowing for seatsPerTimeSlot

When seats is not provided in a partial update, the transformed body
may not have seatsPerTimeSlot property. This fix uses 'in' operator
to safely check for the property before accessing it.

Also fixes the e2e test to properly narrow the union type for seats
using type guards.

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

* refactor: simplify seatsPerTimeSlot handling per review feedback

Per supalarry's suggestion, instead of using 'in' operator checks in
multiple validation calls, we now return {seatsPerTimeSlot: undefined}
when seats is not provided. This keeps the validation code unchanged
and only requires modifying one line in the transformation.

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-02 18:38:01 +00:00
Volnei Munhoz 8b6c6273ee Fix api v1 block paths (#25554) 2025-12-02 16:53:17 +00:00
Rajiv Sahal 957d19740e feat: CalendarView atom v2 (#24896)
* refactor: make individual component for generic and event type specific calendar views

* fixup

* update examples app

* fix: show busy times from calendars in week view

* fix: use calendar busy times not working

* chore: add changesets

* fixup

* chore: implement PR feedback

* fix: merge conflicts

* chore: implement PR feedback

* fixup

* chore: implement PR feedback
2025-12-02 21:44:30 +05:30
Hariom Balhara 150e93dc6e fix: Add /router to isBookingPages to prevent 500 error on customPageMessage redirect (#25522)
* Add /router to isBookingPages

* Remove Dynamic Posthog Provider from AppProvider.tsx because that is only used by the pages router, and the pages router is only in use by the /router endpoint, which is a booking page and we havent implemented corresponding GeoProvider support for it
2025-12-02 13:51:04 +00:00
Anik Dhabal Babu 94c7bfea4d fix: display message in the response (#25102) 2025-12-02 13:46:59 +00:00
Kartik Labhshetwar dbef7644d4 fix: improve availability working hours layout spacing and editable heading icon gap (#25525)
* fix: improve availability working hours layout spacing and editable heading icon gap

* refactor: wrap DateOverride in styled container in AvailabilitySettings
2025-12-02 13:20:14 +00:00
abhix4 a843a38e67 fix: styling override at availability settings (#25521) 2025-12-02 12:41:49 +00:00
Udit Takkar 73ffd9c7b5 chore: update next (#25485) 2025-12-02 12:05:42 +00:00
2c8ff89fac perf: Calendar Cache Improvements (#25502)
* wip

* Filter only selectedCalendars where ff is enabled

* test: fix and add tests for calendar subscription cache feature

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* test: fix SelectedCalendarRepository tests for new teamIds parameter

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-02 08:11:52 -03:00
Benny JooandDevin AI 3bf2a8fa6c refactor: replace TRPCError with ErrorWithCode in packages/features (#25482)
* refactor: replace TRPCError with ErrorWithCode in packages/features

This refactor moves error handling from throwing TRPCError directly in
packages/features to throwing ErrorWithCode instead. The conversion to
TRPCError now happens at the TRPC layer.

Changes:
- Add generic ErrorCode values (Unauthorized, Forbidden, NotFound,
  BadRequest, InternalServerError) to errorCodes.ts
- Update getServerErrorFromUnknown to map new ErrorCodes to proper
  HTTP status codes
- Create toTRPCError helper in packages/trpc/server/lib
- Create errorMappingMiddleware in packages/trpc/server/middlewares
- Migrate TRPCError throws in packages/features to ErrorWithCode:
  - teamService.ts
  - getEventTypeById.ts
  - eventTypeRepository.ts
  - OrganizationPermissionService.ts
  - OrganizationPaymentService.ts
  - sso.ts
  - handleCreatePhoneCall.ts
  - userCanCreateTeamGroupMapping.ts

This improves separation of concerns by making packages/features
transport-agnostic, allowing the same feature code to be reused from
tRPC, API routes, workers, etc.

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

* fix: remove isTrpcCall parameter and fix lint warning

- Remove isTrpcCall parameter from get.handler.ts call since the
  feature layer no longer needs to know about tRPC
- Fix unsafe optional chaining lint warning in getEventTypesByViewer.ts
  by precomputing usersSource variable
- Complete migration of getEventTypesByViewer.ts to ErrorWithCode

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

* revert

* add eslint rule

* add comment

* fix: add isTrpcCall back to getEventTypeById interface

The user reverted the removal of isTrpcCall parameter from the handler,
so we need to add it back to the interface to fix the type error.

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

* test: update teamService tests to expect ErrorWithCode instead of TRPCError

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

* refactor

* wip

* feat: integrate errorMappingMiddleware into base TRPC procedure

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

* connect middlewares

* revert

* revert

* refactor

* rename

* fix: handle ErrorWithCode in teams server-page error handling

The error handling was checking for TRPCError, but teamService now throws
ErrorWithCode. This caused the 'This invitation is not for your account'
error message to not be displayed when a wrong user tries to use an
invitation link.

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

* fix

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-02 08:11:17 -03:00
Hariom Balhara 16a4c75e1e Update yarn.lock (#25524) 2025-12-02 09:58:57 +00:00
sean-brydon f9db1d6111 fix: getTeamIdsWithPermission (#25387) 2025-12-02 06:11:56 -03:00
Anik Dhabal Babu 1d9aee7154 fix: Use booker locale for email verification email (#25420) 2025-12-02 08:25:39 +00:00
abhix4andDhairyashil Shinde 56e3a314a8 fix(ui): add missing checked state styling for event type checkbox (#25478)
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-02 06:47:30 +00:00
374 changed files with 43150 additions and 2255 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": major
---
feat: use tailwind v4 (breaking change)
-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: []
+2
View File
@@ -1,2 +1,4 @@
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
GET /v2/bookings/{bookingUid}/recordings added the new required 'header' request parameter 'Authorization'
GET /v2/bookings/{bookingUid}/transcripts added the new required 'header' request parameter 'Authorization'
+37
View File
@@ -0,0 +1,37 @@
name: Companion Builds
on:
workflow_call:
permissions:
contents: read
env:
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
jobs:
build:
name: Build Companion App & Extension
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: companion/package-lock.json
- name: Install dependencies
working-directory: companion
run: npm ci
- name: Build Expo web app
working-directory: companion
env:
EXPO_NO_TELEMETRY: 1
CI: true
run: npx expo export --platform web
- name: Build browser extension
working-directory: companion
run: npm run ext:build
+8
View File
@@ -146,6 +146,13 @@ jobs:
uses: ./.github/workflows/docs-build.yml
secrets: inherit
build-companion:
name: Companion builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/companion-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes, check-label, deps]
@@ -244,6 +251,7 @@ jobs:
build-api-v2,
build-atoms,
build-docs,
build-companion,
e2e,
e2e-api-v2,
e2e-embed,
-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.*
+193 -19
View File
@@ -1,30 +1,204 @@
# 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`
- `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
+19
View File
@@ -0,0 +1,19 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export function middleware(_request: NextRequest) {
// Matcher guarantees the last segment is one of the blocked segments, so we can
// immediately return a 403 without further path checks.
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
}
export const config = {
// The blocked segment is always the last part of the path, so we only need this matcher.
matcher: [
"/:path*/_get",
"/:path*/_post",
"/:path*/_patch",
"/:path*/_delete",
"/:path*/_auth-middleware",
],
};
+1 -1
View File
@@ -35,7 +35,7 @@
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^15.4.5",
"next": "^15.4.8",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
@@ -1,10 +1,12 @@
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
@@ -96,6 +98,8 @@ import { Module } from "@nestjs/common";
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
CalVideoService,
CalVideoOutputService,
BookingPbacGuard,
],
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -1,3 +1,4 @@
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
@@ -23,6 +24,7 @@ import {
GetOptionalUser,
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
@@ -66,6 +68,7 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingTranscriptsOutput,
GetBookingVideoSessionsOutput,
} from "@calcom/platform-types";
import {
CreateBookingInputPipe,
@@ -209,12 +212,16 @@ export class BookingsController_2024_08_13 {
}
@Get("/:bookingUid/recordings")
// @Pbac(["booking.readRecordings"])
@Permissions([BOOKING_READ])
@UseGuards(BookingUidGuard)
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get all the recordings for the booking",
description: `Fetches all the recordings for the booking \`:bookingUid\`
description: `Fetches all the recordings for the booking \`:bookingUid\`. Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
<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>
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>
`,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
@@ -223,11 +230,17 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: recordings,
message:
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
};
}
@Get("/:bookingUid/transcripts")
// @Pbac(["booking.readRecordings"])
@Permissions([BOOKING_READ])
@UseGuards(BookingUidGuard)
// @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get Cal Video real time transcript download links for the booking",
description: `Fetches all the transcript download links for the booking \`:bookingUid\`
@@ -245,6 +258,8 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: transcripts ?? [],
message:
"This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
};
}
@@ -546,4 +561,25 @@ export class BookingsController_2024_08_13 {
data: bookingReferences,
};
}
@Get("/:bookingUid/conferencing-sessions")
@HttpCode(HttpStatus.OK)
@Pbac(["booking.readRecordings"])
@Permissions([BOOKING_READ])
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get Video Meeting Sessions. Only supported for Cal Video",
description: `Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>`,
})
async getVideoSessions(@Param("bookingUid") bookingUid: string): Promise<GetBookingVideoSessionsOutput> {
const sessions = await this.calVideoService.getVideoSessions(bookingUid);
return {
status: SUCCESS_STATUS,
data: sessions,
};
}
}
@@ -0,0 +1,245 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Booking access authorization", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let oAuthClient: PlatformOAuthClient;
const ownerEmail = `booking-access-auth-owner-${randomString()}@api.com`;
const unauthorizedEmail = `booking-access-auth-unauthorized-${randomString()}@api.com`;
let ownerUser: User;
let unauthorizedUser: User;
let ownerApiKey: string;
let unauthorizedApiKey: string;
let eventTypeId: number;
let testBooking: Booking;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({
name: `booking-access-auth-organization-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
ownerUser = await userRepositoryFixture.create({
email: ownerEmail,
locale: "en",
name: `booking-access-auth-owner-${randomString()}`,
});
unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedEmail,
locale: "en",
name: `booking-access-auth-unauthorized-${randomString()}`,
});
const { keyString: ownerKeyString } = await apiKeysRepositoryFixture.createApiKey(ownerUser.id, null);
ownerApiKey = `cal_test_${ownerKeyString}`;
const { keyString: unauthorizedKeyString } = await apiKeysRepositoryFixture.createApiKey(
unauthorizedUser.id,
null
);
unauthorizedApiKey = `cal_test_${unauthorizedKeyString}`;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `booking-access-auth-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(ownerUser.id, userSchedule);
const eventType = await eventTypesRepositoryFixture.create(
{
title: `booking-access-auth-event-type-${randomString()}`,
slug: `booking-access-auth-event-type-${randomString()}`,
length: 60,
},
ownerUser.id
);
eventTypeId = eventType.id;
testBooking = await bookingsRepositoryFixture.create({
uid: `booking-access-auth-booking-${randomString()}`,
title: "Test Booking for Access Auth",
description: "",
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
eventType: {
connect: {
id: eventTypeId,
},
},
user: {
connect: {
id: ownerUser.id,
},
},
metadata: {},
responses: {
name: "Test Attendee",
email: "attendee@example.com",
},
references: {
create: [
{
type: "daily_video",
uid: `daily-room-${randomString()}`,
meetingId: `daily-room-${randomString()}`,
meetingPassword: "test-password",
meetingUrl: `https://daily.co/test-room-${randomString()}`,
},
],
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
describe("GET /v2/bookings/:bookingUid/conferencing-sessions - Authorization", () => {
it("should allow booking organizer to access conferencing sessions", async () => {
const calVideoService = app.get(CalVideoService);
jest.spyOn(calVideoService, "getVideoSessions").mockResolvedValue([]);
const response = await request(app.getHttpServer())
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
.set("Authorization", `Bearer ${ownerApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200);
expect(response.body.status).toEqual(SUCCESS_STATUS);
});
it("should return 403 when unauthorized user tries to access conferencing sessions", async () => {
await request(app.getHttpServer())
.get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
.set("Authorization", `Bearer ${unauthorizedApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
});
});
// describe("GET /v2/bookings/:bookingUid/recordings - Authorization", () => {
// it("should allow booking organizer to access recordings", async () => {
// const calVideoService = app.get(CalVideoService);
// jest.spyOn(calVideoService, "getRecordings").mockResolvedValue([]);
// const response = await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
// .set("Authorization", `Bearer ${ownerApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(200);
// expect(response.body.status).toEqual(SUCCESS_STATUS);
// });
// it("should return 403 when unauthorized user tries to access recordings", async () => {
// await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/recordings`)
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(403);
// });
// });
// describe("GET /v2/bookings/:bookingUid/transcripts - Authorization", () => {
// it("should allow booking organizer to access transcripts", async () => {
// const calVideoService = app.get(CalVideoService);
// jest.spyOn(calVideoService, "getTranscripts").mockResolvedValue([]);
// const response = await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
// .set("Authorization", `Bearer ${ownerApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(200);
// expect(response.body.status).toEqual(SUCCESS_STATUS);
// });
// it("should return 403 when unauthorized user tries to access transcripts", async () => {
// await request(app.getHttpServer())
// .get(`/v2/bookings/${testBooking.uid}/transcripts`)
// .set("Authorization", `Bearer ${unauthorizedApiKey}`)
// .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
// .expect(403);
// });
// });
afterAll(async () => {
await bookingsRepositoryFixture.deleteById(testBooking.id);
await eventTypesRepositoryFixture.delete(eventTypeId);
await userRepositoryFixture.deleteByEmail(ownerEmail);
await userRepositoryFixture.deleteByEmail(unauthorizedEmail);
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await app.close();
});
});
});
@@ -4,6 +4,7 @@ import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
@@ -3078,6 +3079,102 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
}
describe("Meeting sessions", () => {
it("should get cal video sessions for a booking", async () => {
const mockSessions = [
{
id: "session-123",
room: "daily-room-123",
start_time: 1678901234,
duration: 3600,
ongoing: false,
max_participants: 10,
participants: [
{
user_id: "user-1",
participant_id: "participant-1",
user_name: "John Doe",
join_time: 1678901234,
duration: 3600,
},
],
},
];
const booking = await bookingsRepositoryFixture.create({
uid: `test-video-session-${randomString()}`,
title: "Test Video Session Booking",
description: "",
startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
eventType: {
connect: {
id: eventTypeId,
},
},
user: {
connect: {
id: user.id,
},
},
references: {
create: [
{
type: "daily_video",
uid: `daily-room-123-${randomString()}`,
meetingId: "daily-room-123",
meetingPassword: "test-password",
meetingUrl: "https://daily.co/daily-room-123",
},
],
},
});
const calVideoService = app.get(CalVideoService);
jest
.spyOn(calVideoService, "getVideoSessions")
.mockResolvedValue([
{
id: mockSessions[0].id,
room: mockSessions[0].room,
startTime: mockSessions[0].start_time,
duration: mockSessions[0].duration,
ongoing: mockSessions[0].ongoing,
maxParticipants: mockSessions[0].max_participants,
participants: mockSessions[0].participants.map((p) => ({
userId: p.user_id,
userName: p.user_name,
joinTime: p.join_time,
duration: p.duration,
})),
},
]);
const response = await request(app.getHttpServer())
.get(`/v2/bookings/${booking.uid}/conferencing-sessions`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200);
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeDefined();
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(1);
expect(response.body.data[0].id).toEqual(mockSessions[0].id);
expect(response.body.data[0].room).toEqual(mockSessions[0].room);
expect(response.body.data[0].startTime).toEqual(mockSessions[0].start_time);
expect(response.body.data[0].duration).toEqual(mockSessions[0].duration);
expect(response.body.data[0].ongoing).toEqual(mockSessions[0].ongoing);
expect(response.body.data[0].maxParticipants).toEqual(mockSessions[0].max_participants);
expect(response.body.data[0].participants.length).toBe(1);
expect(response.body.data[0].participants[0].userId).toEqual(mockSessions[0].participants[0].user_id);
expect(response.body.data[0].participants[0].userName).toEqual(
mockSessions[0].participants[0].user_name
);
await bookingsRepositoryFixture.deleteById(booking.id);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -0,0 +1,52 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
BadRequestException,
} from "@nestjs/common";
import { Request } from "express";
import { BookingAccessService } from "@calcom/platform-libraries";
@Injectable()
export class BookingPbacGuard implements CanActivate {
private bookingAccessService: BookingAccessService;
constructor(private readonly prismaReadService: PrismaReadService) {
this.bookingAccessService = new BookingAccessService(this.prismaReadService.prisma);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { user?: ApiAuthGuardUser; pbacAuthorizedRequest?: boolean }>();
const user = request.user;
const bookingUid = request.params.bookingUid;
if (!user) {
throw new UnauthorizedException();
}
if (!bookingUid) {
throw new BadRequestException("BookingPbacGuard - bookingUid is required");
}
const hasAccess = await this.bookingAccessService.doesUserIdHaveAccessToBooking({
userId: user.id,
bookingUid,
});
if (!hasAccess) {
throw new ForbiddenException(
`BookingPbacGuard - user with id=${user.id} does not have access to booking with uid=${bookingUid}`
);
}
request.pbacAuthorizedRequest = true;
return true;
}
}
@@ -0,0 +1,23 @@
import { Injectable } from "@nestjs/common";
import type { CalMeetingSession } from "@calcom/platform-libraries/conferencing";
@Injectable()
export class CalVideoOutputService {
getOutputVideoSessions(sessions: CalMeetingSession[]) {
return sessions.map((session) => ({
id: session.id,
room: session.room,
startTime: session.start_time,
duration: session.duration,
ongoing: session.ongoing,
maxParticipants: session.max_participants,
participants: session.participants.map((participant) => ({
userId: participant.user_id,
userName: participant.user_name,
joinTime: participant.join_time,
duration: participant.duration,
})),
}));
}
}
@@ -1,11 +1,13 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { CAL_VIDEO_TYPE } from "@calcom/platform-constants";
import {
getRecordingsOfCalVideoByRoomName,
getAllTranscriptsAccessLinkFromRoomName,
getDownloadLinkOfCalVideoByRecordingId,
getCalVideoMeetingSessionsByRoomName,
} from "@calcom/platform-libraries/conferencing";
@Injectable()
@@ -13,18 +15,23 @@ export class CalVideoService {
private readonly logger = new Logger("CalVideoService");
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13
private readonly calVideoOutputService: CalVideoOutputService
) {}
private getVideoSessionsRoomName(references?: Array<{ type: string; meetingId?: string | null }>) {
return (
references?.filter((reference) => reference.type === CAL_VIDEO_TYPE)?.pop()?.meetingId ??
undefined
);
}
async getRecordings(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName =
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
undefined;
const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
@@ -68,10 +75,7 @@ export class CalVideoService {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName =
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
undefined;
const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
@@ -80,4 +84,19 @@ export class CalVideoService {
return transcripts;
}
async getVideoSessions(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
const sessions = await getCalVideoMeetingSessionsByRoomName(roomName);
return this.calVideoOutputService.getOutputVideoSessions(sessions.data);
}
}
@@ -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";
@@ -252,7 +253,7 @@ export class InputEventTypesService_2024_06_14 {
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
eventTypeColor: this.transformInputEventTypeColor(color),
...this.transformInputSeatOptions(seats),
...(seats ? this.transformInputSeatOptions(seats) : { seatsPerTimeSlot: undefined }),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
...maxActiveBookingsPerBooker,
@@ -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;
}
}
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/reposito
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
@@ -44,6 +45,7 @@ import { Module } from "@nestjs/common";
ManagedOrganizationsBillingService,
OAuthClientRepository,
BookingsRepository_2024_08_13,
IsUserInBillingOrg,
],
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
controllers: [BillingController],
@@ -17,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
import { withApiAuth } from "test/utils/withApiAuth";
import type { Team, PlatformBilling } from "@calcom/prisma/client";
@@ -32,8 +32,10 @@ describe("Platform Billing Controller (e2e)", () => {
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;
let organization2: Team;
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -49,6 +51,10 @@ describe("Platform Billing Controller (e2e)", () => {
name: `billing-organization-${randomString()}`,
isPlatform: true,
});
organization2 = await organizationsRepositoryFixture.create({
name: `billing-organization-2-${randomString()}`,
isPlatform: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
@@ -133,7 +139,7 @@ describe("Platform Billing Controller (e2e)", () => {
});
});
it("/billing/webhook (GET) should get billing plan for org", () => {
it("/billing/:orgId/check (GET) should check billing plan for org", () => {
return request(app.getHttpServer())
.get(`/v2/billing/${organization.id}/check`)
.expect(200)
@@ -143,6 +149,10 @@ describe("Platform Billing Controller (e2e)", () => {
});
});
it("/billing/:organizationId/check (GET) should not be able to check other org plan", () => {
return request(app.getHttpServer()).get(`/v2/billing/${organization2.id}/check`).expect(403);
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
@@ -1,11 +1,13 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { StripeService } from "@/modules/stripe/stripe.service";
import {
@@ -49,8 +51,9 @@ export class BillingController {
}
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async checkTeamBilling(
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -66,8 +69,9 @@ export class BillingController {
}
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN"])
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -84,8 +88,9 @@ export class BillingController {
}
@Post("/:teamId/upgrade")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN"])
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async upgradeTeamBillingInStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -100,13 +105,12 @@ export class BillingController {
};
}
@Delete("/:organizationId/unsubscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Delete("/:teamId/unsubscribe")
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg)
@MembershipRoles(["OWNER", "ADMIN"])
async cancelTeamSubscriptionInStripe(
@Param("organizationId") organizationId: number
): Promise<ApiResponse> {
await this.billingService.cancelTeamSubscription(organizationId);
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
async cancelTeamSubscriptionInStripe(@Param("teamId") teamId: number): Promise<ApiResponse> {
await this.billingService.cancelTeamSubscription(teamId);
return {
status: "success",
@@ -0,0 +1,35 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import type { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserInBillingOrg implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const orgId: string = request.params.teamId;
const userId = (request.user as ApiAuthGuardUser).id;
if (!userId) {
throw new ForbiddenException("IsUserInBillingOrg - No user id found.");
}
if (!orgId) {
throw new ForbiddenException("IsUserInBillingOrg - No org id found in request params.");
}
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
if (!user) {
throw new ForbiddenException(
`IsUserInBillingOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
);
}
return true;
}
}
@@ -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 },
@@ -1230,6 +1230,70 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("should preserve seatsPerTimeSlot when doing partial update", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation with seats",
slug: `organizations-event-types-seats-${randomString()}`,
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teammate1.id,
},
],
seats: {
seatsPerTimeSlot: 5,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: TeamEventTypeOutput_2024_06_14 = createResponse.body.data;
const createdSeats = createdEventType.seats;
expect(createdSeats).toBeDefined();
expect(createdSeats && "seatsPerTimeSlot" in createdSeats).toBe(true);
if (createdSeats && "seatsPerTimeSlot" in createdSeats) {
expect(createdSeats.seatsPerTimeSlot).toEqual(5);
expect(createdSeats.showAttendeeInfo).toEqual(true);
expect(createdSeats.showAvailabilityCount).toEqual(true);
}
// Now do a partial update that only changes a different field (not seats)
const updateBody: UpdateTeamEventTypeInput_2024_06_14 = {
bookingRequiresAuthentication: false,
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${createdEventType.id}`)
.send(updateBody)
.expect(200);
const updatedEventType: TeamEventTypeOutput_2024_06_14 = updateResponse.body.data;
// Verify that seatsPerTimeSlot is preserved and not reset to null
const updatedSeats = updatedEventType.seats;
expect(updatedSeats).toBeDefined();
expect(updatedSeats && "seatsPerTimeSlot" in updatedSeats).toBe(true);
if (updatedSeats && "seatsPerTimeSlot" in updatedSeats) {
expect(updatedSeats.seatsPerTimeSlot).toEqual(5);
expect(updatedSeats.showAttendeeInfo).toEqual(true);
expect(updatedSeats.showAvailabilityCount).toEqual(true);
}
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
@@ -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(
@@ -188,7 +188,8 @@ export class SlotsController_2024_09_04 {
@DocsResponse({
status: 200,
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
For seated slots each object will have attendeesCount and bookingUid properties.`,
For seated slots each object will have attendeesCount and bookingUid properties.
If no slots are available, the data field will be an empty object {}.`,
schema: {
oneOf: [
{
@@ -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;
@@ -6,11 +6,10 @@ import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { ErrorWithCode } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { TeamsCTA } from "./CTA";
const getCachedTeams = unstable_cache(
@@ -52,7 +51,7 @@ export const ServerTeamsListing = async ({
}
} catch (e) {
errorMsgFromInvite = "Error while fetching teams";
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
if (e instanceof ErrorWithCode) errorMsgFromInvite = e.message;
}
}
@@ -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;
@@ -34,7 +34,7 @@ const ServerPage = async () => {
return <TeamInviteEmailView userEmail={userEmail} />;
}
return <TeamInviteView userEmail={userEmail} />;
return <TeamInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -91,6 +91,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/organizations/general",
trackingMetadata: { section: "organization", page: "general" },
},
{
name: "guest_notifications",
href: "/settings/organizations/guest-notifications",
},
...(orgBranding
? [
{
@@ -371,22 +375,26 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
const [teamMenuState, setTeamMenuState] =
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const searchParamsId = searchParams?.get("id");
const pathTeamId = pathname?.match(/\/settings\/teams\/(\d+)/)?.[1];
const activeTeamId = pathTeamId || searchParamsId;
useEffect(() => {
if (teams) {
const teamStates = teams?.map((team) => ({
teamId: team.id,
teamMenuOpen: String(team.id) === searchParamsId,
teamMenuOpen: String(team.id) === activeTeamId,
}));
setTeamMenuState(teamStates);
setTimeout(() => {
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
)[1];
tabMembers?.scrollIntoView({ behavior: "smooth" });
}, 100);
if (activeTeamId) {
setTimeout(() => {
const teamTrigger = document.querySelector(`[aria-controls="team-content-${activeTeamId}"]`);
teamTrigger?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
}
}, [searchParamsId, teams]);
}, [activeTeamId, teams]);
return (
<>
@@ -0,0 +1,55 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { redirect } from "next/navigation";
import GuestNotificationsView from "@calcom/features/ee/organizations/pages/settings/guest-notifications";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("guest_notifications"),
(t) => t("guest_notifications_description"),
undefined,
undefined,
"/settings/organizations/guest-notifications"
);
const Page = async () => {
const session = await validateUserHasOrg();
const t = await getTranslate();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("guest_notifications")} description={t("guest_notifications_description")}>
<GuestNotificationsView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
export default Page;
@@ -5,13 +5,11 @@ import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { getTeamMemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -33,7 +31,7 @@ const getCachedTeamRoles = unstable_cache(
try {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
return await roleManager.getTeamRoles(teamId);
} catch (error) {
} catch {
// PBAC not enabled or error occurred, return empty array
return [];
}
@@ -49,7 +47,7 @@ const getCachedTeamAttributes = unstable_cache(
try {
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
} catch {
return [];
}
},
@@ -79,65 +77,15 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const organizationId = team.parentId || teamId;
// Load PBAC roles and attributes if available
const [roles, attributes] = await Promise.all([
const [roles, attributes, memberPermissions] = await Promise.all([
getCachedTeamRoles(teamId, organizationId),
getCachedTeamAttributes(organizationId),
getTeamMemberPermissions({
userId: session.user.id,
team,
}),
]);
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
// If the team is not private we allow members to list other members
if (!team.isPrivate) {
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
}
// Get specific PBAC permissions for team member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: teamId,
resource: Resource.Team,
userRole: team.membership.role,
actions: [
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembers]: {
roles: fallbackRolesCanListMembers,
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesCanListMembers,
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: team.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
};
const facetedTeamValues = {
roles,
teams: [team],
@@ -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);
+9
View File
@@ -1,3 +1,12 @@
/**
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router
*
* Currently only serves the /router endpoint (routing forms redirect page).
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
*
* For App Router, use PageWrapperAppDir.tsx instead.
*/
"use client";
import { DefaultSeo } from "next-seo";
@@ -36,12 +36,13 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
userName,
}) => {
const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata);
const durations =
parsedMetaData.success &&
parsedMetaData.data?.multipleDuration &&
Boolean(parsedMetaData.data?.multipleDuration.length)
? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort()
: [length];
const multipleDuration = parsedMetaData.success && parsedMetaData.data?.multipleDuration
? parsedMetaData.data.multipleDuration
: [];
const durations = multipleDuration.length > 0
? [length, ...multipleDuration.filter((duration) => duration !== length)].sort()
: [length];
return (
<div
data-testid={`select-event-type-${id}`}
@@ -50,7 +51,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
<input
id={`${id}`}
checked={selected}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-cal checked:border-transparent checked:bg-gray-800 border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
/>
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
@@ -64,7 +65,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
{Boolean(description) && (
<div
className="text-subtle line-clamp-4 wrap-break-word text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(description),
}}
@@ -452,7 +452,8 @@ describe("TestFormDialog", () => {
fireEvent.click(screen.getByText("submit"));
// Verify the URL shows the substituted value, not the variable
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("/team/sales-team/meeting");
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("/team/Sales%20Team/meeting");
expect(screen.getByTestId("test-routing-result")).not.toHaveTextContent("/team/sales-team/meeting");
expect(screen.getByTestId("test-routing-result")).not.toHaveTextContent("{name}");
});
@@ -356,7 +356,7 @@ function BookingListItem(booking: BookingItemProps) {
data-today={String(booking.isToday)}
className="hover:bg-cal-muted group w-full">
<div className="flex flex-col sm:flex-row">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-48">
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
@@ -445,6 +445,13 @@ function BookingListItem(booking: BookingItemProps) {
{t("pending_payment")}
</Badge>
)}
{isRescheduled && (
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
{t("rescheduled")}
</Badge>
</Tooltip>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip
@@ -497,7 +504,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</ConditionalLink>
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 stack-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:stack-y-0 sm:pl-0">
<div className="stack-y-2 sm:stack-y-0 flex w-full flex-col flex-wrap items-end justify-end space-x-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
<BookingActionsDropdown booking={booking} context="list" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
@@ -598,7 +598,7 @@ export function BookingActionsDropdown({
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
href={action.disabled ? undefined : action.href}
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation();
@@ -621,7 +621,7 @@ export function BookingActionsDropdown({
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
href={action.disabled ? undefined : action.href}
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation();
@@ -642,7 +642,7 @@ export function BookingActionsDropdown({
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
href={action.disabled ? undefined : action.href}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
+20 -23
View File
@@ -1,3 +1,12 @@
/**
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router (_app.tsx)
*
* Currently only serves the /router endpoint (routing forms redirect page).
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
*
* For App Router, use app-providers-app-dir.tsx instead.
*/
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
@@ -12,7 +21,6 @@ import type { ParsedUrlQuery } from "querystring";
import type { PropsWithChildren, ReactNode } from "react";
import { useEffect } from "react";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
@@ -53,12 +61,6 @@ export type AppProps = Omit<
err?: Error;
};
const PostHogPageView = dynamic(
() => import("@calcom/features/ee/event-tracking/lib/posthog/web/PostHogPageView"),
{
ssr: false,
}
);
type AppPropsWithChildren = AppProps & {
children: ReactNode;
@@ -134,8 +136,8 @@ const enum ThemeSupport {
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps, "router"> &
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
@@ -209,8 +211,8 @@ function getThemeProviderProps({
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
? ThemeSupport.None
: ThemeSupport.App;
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = props.themeBasis;
@@ -234,13 +236,13 @@ function getThemeProviderProps({
const storageKey = isEmbedMode
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
: themeSupport === ThemeSupport.App
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
return {
storageKey,
@@ -307,12 +309,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
return (
<>
<DynamicHelpscoutProvider>
<DynamicPostHogProvider>
<PostHogPageView />
{RemainingProviders}
</DynamicPostHogProvider>
</DynamicHelpscoutProvider>
<DynamicHelpscoutProvider>{RemainingProviders}</DynamicHelpscoutProvider>
</>
);
};
+4 -1
View File
@@ -1,7 +1,9 @@
import { usePathname } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
// TODO: This approach of checking booking page isn't correct.
// app.cal.com/rick is a booking page but useIsBookingPage won't return true. This is because all unregistered router in Next.js could technically be a booking page throw catch all routes.
// The only way to confirm it is by actually checking if we actually rendered a booking route.
export default function useIsBookingPage(): boolean {
const pathname = usePathname();
const isBookingPage = [
@@ -13,6 +15,7 @@ export default function useIsBookingPage(): boolean {
"/d", // Private Link of booking page
"/apps/routing-forms/routing-link", // Routing Form page
"/forms/", // Rewrites to /apps/routing-forms/routing-link
"/router", // Headless router page - Loads as a page when redirect type is customPageMessage
].some((route) => pathname?.startsWith(route));
const isBookingsListPage = ["/upcoming", "/unconfirmed", "/recurring", "/cancelled", "/past"].some(
(route) => pathname?.endsWith(route)
@@ -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>
);
}
@@ -50,7 +50,7 @@ export function EmailInviteForm({
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-col gap-2">
{showTeamSelect ? (
<div className="grid grid-cols-2">
<div className="mr-7 grid grid-cols-2">
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
{t("email")}
</Label>
@@ -32,7 +32,7 @@ export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children
{/* Logo and container - centered */}
<div className="flex w-full flex-1 flex-col items-center justify-center gap-6">
<Logo className="mt-4 h-5 w-auto shrink-0" />
<div className="border-subtle bg-default grid max-h-[690px] min-h-0 w-full max-w-[532px] flex-1 grid-cols-1 gap-6 overflow-hidden rounded-2xl border px-4 py-2 sm:px-12 sm:py-10 xl:h-[690px] xl:max-w-[1130px] xl:grid-cols-[40%_1fr] xl:pl-10 xl:pr-0">
<div className="border-subtle bg-default grid max-h-[690px] min-h-0 w-full max-w-[532px] flex-1 grid-cols-1 gap-12 overflow-hidden rounded-2xl border px-4 py-2 sm:px-12 sm:py-10 xl:h-[690px] xl:max-w-[1130px] xl:grid-cols-[40%_1fr] xl:pl-10 xl:pr-0">
{/* Column 1 - Always visible, 40% on xl+ */}
<div className="flex h-full min-h-0 flex-col">{column1}</div>
{/* Column 2 - Hidden on mobile, visible on xl+, 60% on xl+ */}
@@ -4,6 +4,8 @@ import classNames from "classnames";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -18,6 +20,21 @@ type OnboardingBrowserViewProps = {
teamSlug?: string;
};
const getDisplayUrl = (
orgSlug: string | null | undefined,
username: string | null | undefined,
teamSlug: string | undefined
): string => {
if (orgSlug) {
return teamSlug !== undefined
? `${orgSlug}.${subdomainSuffix()}/team/${teamSlug || ""}`
: `${orgSlug}.${subdomainSuffix()}/${username || ""}`;
}
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
return teamSlug !== undefined ? `${webappUrl}/team/${teamSlug || ""}` : `${webappUrl}/${username || ""}`;
};
export const OnboardingBrowserView = ({
avatar,
name,
@@ -27,9 +44,9 @@ export const OnboardingBrowserView = ({
}: OnboardingBrowserViewProps) => {
const { t } = useLocale();
const pathname = usePathname();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const displayUrl =
teamSlug !== undefined ? `${webappUrl}/team/${teamSlug || ""}` : `${webappUrl}/${username || ""}`;
const orgBranding = useOrgBranding();
const displayUrl = getDisplayUrl(orgBranding?.slug, username, teamSlug);
// Animation variants for entry and exit
const containerVariants = {
@@ -133,22 +133,44 @@ export const OnboardingInviteBrowserView = ({
duration: 0.5,
ease: "backOut",
}}>
<div className="bg-default border-subtle flex flex-col gap-4 rounded-2xl border p-8">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={displayName}
className="border-default h-12 w-12 border-2"
/>
<div className="flex w-full flex-col items-start gap-1">
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
{displayName}
</h2>
<p className="text-subtle text-left text-sm font-normal leading-tight">
{displayBio || "We're emailing you all the details"}
</p>
</div>
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
<div className="relative p-1">
{/* Banner Image */}
{organizationBrand.banner && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Organization Avatar - Overlaying the banner */}
{organizationBrand.banner && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
</div>
)}
</div>
{/* Organization Info */}
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
{!organizationBrand.banner && avatar && (
<Avatar
size="lg"
imageSrc={avatar}
alt={displayName}
className="border-default mb-4 h-12 w-12 border-2"
/>
)}
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
{displayName}
</h2>
<p className="text-subtle text-left text-sm font-normal leading-tight">
{displayBio || "We're emailing you all the details"}
</p>
</div>
</div>
@@ -3,19 +3,30 @@
import classNames from "classnames";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Icon, type IconName } from "@calcom/ui/components/icon";
// Helper function to darken a hex color
const darkenColor = (hex: string, amount: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xff) * (1 - amount)));
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xff) * (1 - amount)));
const b = Math.max(0, Math.min(255, (num & 0xff) * (1 - amount)));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
};
type OnboardingOrganizationBrowserViewProps = {
avatar?: string | null;
name?: string;
bio?: string;
slug?: string;
bannerUrl?: string | null;
brandColor?: string;
};
export const OnboardingOrganizationBrowserView = ({
@@ -24,11 +35,22 @@ export const OnboardingOrganizationBrowserView = ({
bio,
slug,
bannerUrl,
brandColor,
}: OnboardingOrganizationBrowserViewProps) => {
const { t } = useLocale();
const pathname = usePathname();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const displayUrl = `${webappUrl}/${slug || ""}`;
const containerRef = useRef<HTMLDivElement>(null);
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
// Update CSS variables when brandColor changes
useEffect(() => {
if (brandColor && containerRef.current) {
containerRef.current.style.setProperty("--cal-brand", brandColor);
// Set brand-emphasis as a slightly darker version for hover states
const darkerColor = darkenColor(brandColor, 0.1);
containerRef.current.style.setProperty("--cal-brand-emphasis", darkerColor);
}
}, [brandColor]);
// Animation variants for entry and exit
const containerVariants = {
@@ -79,7 +101,9 @@ export const OnboardingOrganizationBrowserView = ({
];
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
<div
ref={containerRef}
className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
{/* Browser header */}
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
{/* Navigation buttons */}
@@ -164,7 +188,7 @@ export const OnboardingOrganizationBrowserView = ({
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right" tabIndex={-1}>
<Button color="primary" size="sm" EndIcon="arrow-right" tabIndex={-1}>
{t("book_now")}
</Button>
</div>
@@ -108,11 +108,6 @@ export const OnboardingTeamsBrowserView = ({
{/* Teams Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
<p className="text-subtle text-sm leading-normal">
{hasValidTeams
? t("onboarding_teams_browser_view_subtitle")
: t("onboarding_teams_browser_view_placeholder_subtitle")}
</p>
</div>
</div>
@@ -25,10 +25,15 @@ type OnboardingViewProps = {
export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { selectedPlan, setSelectedPlan } = useOnboardingStore();
const { selectedPlan, setSelectedPlan, resetOnboardingPreservingPlan } = useOnboardingStore();
const previousPlanRef = useRef<PlanType | null>(null);
const [isPending, startTransition] = useTransition();
// Reset onboarding data when visiting this page, but preserve the selected plan
useEffect(() => {
resetOnboardingPreservingPlan();
}, []);
// Plan order mapping for determining direction
const planOrder: Record<PlanType, number> = {
personal: 0,
@@ -18,11 +18,13 @@ export function useCreateTeam() {
const createTeamMutation = trpc.viewer.teams.create.useMutation();
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
const createTeam = async (store: OnboardingState) => {
const createTeam = async (store?: OnboardingState) => {
setIsSubmitting(true);
try {
const { teamDetails, teamBrand } = store;
// Get the latest state from the store to ensure we have the most up-to-date values
const currentStore = store || useOnboardingStore.getState();
const { teamDetails, teamBrand } = currentStore;
// Validate team details - if empty, redirect back to team details step
if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) {
@@ -103,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
@@ -1,4 +1,3 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
@@ -10,7 +9,6 @@ import { showToast } from "@calcom/ui/components/toast";
import type { OnboardingState } from "../store/onboarding-store";
export const useSubmitOnboarding = () => {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const flags = useFlagMap();
@@ -94,7 +92,9 @@ export const useSubmitOnboarding = () => {
const skipToPersonal = (resetOnboarding: () => void) => {
resetOnboarding();
const gettingStartedPath = flags["onboarding-v3"] ? "/onboarding/personal/settings" : "/getting-started";
router.push(gettingStartedPath);
// Use window.location.href for a full page reload to ensure JWT callback runs
// without trigger="update", which will call autoMergeIdentities() and fetch org data
window.location.href = gettingStartedPath;
};
return {
@@ -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 */}
@@ -189,6 +199,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={bannerPreview}
brandColor={brandColor}
/>
</OnboardingLayout>
);
@@ -37,12 +37,9 @@ 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>
<p className="text-subtle hidden text-left text-xs leading-tight xl:line-clamp-2">{app.description}</p>
<InstallAppButtonWithoutPlanCheck
type={app.type}
options={installOptions}
@@ -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"
@@ -87,6 +87,7 @@ export interface OnboardingState {
// Reset
resetOnboarding: () => void;
resetOnboardingPreservingPlan: () => void;
}
const initialState = {
@@ -167,6 +168,11 @@ export const useOnboardingStore = create<OnboardingState>()(
})),
resetOnboarding: () => set(initialState),
resetOnboardingPreservingPlan: () =>
set((state) => ({
...initialState,
selectedPlan: state.selectedPlan,
})),
}),
{
name: "cal-onboarding-storage", // Storage key
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
@@ -65,7 +65,9 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
setTeamLogo(newLogo);
};
const handleContinue = async () => {
const handleContinue = async (e?: FormEvent) => {
e?.preventDefault();
if (!isSlugValid) {
return;
}
@@ -81,7 +83,8 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
});
// Create the team (will handle payment redirect if needed)
await createTeam(store);
// Don't pass store - let createTeam read the latest state from the store
await createTeam();
};
return (
@@ -89,82 +92,87 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_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>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
<form onSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_logo_size_hint")}
</p>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
</div>
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingCard>
</form>
</div>
{/* Right column - Browser view */}
@@ -54,7 +54,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
email: z.union([z.literal(""), z.string().email(t("invalid_email_address"))]),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
@@ -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) => {
@@ -137,33 +137,33 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("team_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
<Form form={form} handleSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("team_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex items-center gap-2">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}>
{t("continue")}
</Button>
</div>
</div>
</div>
}>
<div className="flex w-full flex-col gap-4 px-1">
<Form form={form} handleSubmit={handleContinue} className="w-full">
}>
<div className="flex w-full flex-col gap-4 px-1">
<div className="flex w-full flex-col gap-4">
<EmailInviteForm
fields={fields}
@@ -184,9 +184,9 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
showInfoBadge
/>
</div>
</Form>
</div>
</OnboardingCard>
</div>
</OnboardingCard>
</Form>
</div>
{/* Right column - Browser view */}
+64 -12
View File
@@ -7,12 +7,12 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Script from "next/script";
import posthog from "posthog-js";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { Toaster } from "sonner";
import { z } from "zod";
import posthog from "posthog-js";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
@@ -41,7 +41,7 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { PasswordField, CheckboxField, TextField, Form } from "@calcom/ui/components/form";
import { PasswordField, CheckboxField, TextField, Form, SelectField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
@@ -337,7 +337,6 @@ export default function Signup({
}}
/>
<noscript
dangerouslySetInnerHTML={{
__html: `<iframe src="https://www.googletagmanager.com/ns.html?id=${process.env.NEXT_PUBLIC_GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`,
}}
@@ -393,6 +392,56 @@ export default function Signup({
})}
</p>
)}
{IS_CALCOM && (
<div className="mt-4">
<SelectField
label={t("data_region")}
value={{
label: t(
// Use WEBAPP_URL for SSR-safe region detection
WEBAPP_URL.includes("cal.eu") ||
(typeof window !== "undefined" &&
window.location.hostname === "localhost" &&
new URL(window.location.href).searchParams.get("region") === "eu")
? "european_union"
: "united_states"
),
value:
// Use WEBAPP_URL for SSR-safe region detection
WEBAPP_URL.includes("cal.eu") ||
(typeof window !== "undefined" &&
window.location.hostname === "localhost" &&
new URL(window.location.href).searchParams.get("region") === "eu")
? "eu"
: "us",
}}
options={[
{ label: t("united_states"), value: "us" },
{ label: t("european_union"), value: "eu" },
]}
onChange={(option) => {
if (option && "value" in option) {
const currentUrl = new URL(window.location.href);
// Handle localhost - add region as URL parameter
if (currentUrl.hostname === "localhost") {
currentUrl.searchParams.set("region", option.value);
window.location.href = currentUrl.toString();
return;
}
// Handle production domains - modify hostname only to preserve query params
if (option.value === "eu") {
currentUrl.hostname = currentUrl.hostname.replace("cal.com", "cal.eu");
} else {
currentUrl.hostname = currentUrl.hostname.replace("cal.eu", "cal.com");
}
window.location.href = currentUrl.toString();
}
}}
/>
</div>
)}
</div>
{/* Form Container */}
@@ -426,14 +475,14 @@ export default function Signup({
addOnLeading={
orgSlug
? truncateDomain(
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
URL_PROTOCOL_REGEX,
""
)}/`
)
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
URL_PROTOCOL_REGEX,
""
)}/`
)
: truncateDomain(
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
)
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
)
}
/>
) : null}
@@ -565,7 +614,10 @@ export default function Signup({
<>
{/* eslint-disable @next/next/no-img-element */}
<img
className={classNames("text-subtle mr-2 h-4 w-4", premiumUsername && "opacity-50")}
className={classNames(
"text-subtle mr-2 h-4 w-4",
premiumUsername && "opacity-50"
)}
src="/google-icon-colored.svg"
alt="Continue with Google Icon"
/>
@@ -748,7 +800,7 @@ export default function Signup({
</div>
</>
)}
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/3 dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
<div className="border-default bg-black/3 hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
{/* eslint-disable @next/next/no-img-element */}
<img
@@ -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"
@@ -445,7 +445,8 @@ export function LogInOverlay(props: LogInOverlayProps) {
<DialogContent
title={t("join_video_call")}
description={t("choose_how_you_d_like_to_appear_on_the_call")}
className="p-6 sm:max-w-lg">
className="p-6 sm:max-w-lg"
onInteractOutside={(e) => e.preventDefault()}>
<div className="mt-2 pb-4">
<div className="stack-y-4">
<div>
@@ -516,7 +517,7 @@ export function LogInOverlay(props: LogInOverlayProps) {
{/* Divider */}
<hr className="my-6 h-0.5 border-t-0 bg-neutral-100 dark:bg-white/10" />
<div className="mt-5 stack-y-4">
<div className="stack-y-4 mt-5">
<div>
<h4 className="text-base font-semibold text-black dark:text-white">
{t("sign_in_to_cal_com")}
@@ -568,7 +569,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
"no-scrollbar fixed left-0 top-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
open ? "translate-x-0" : "-translate-x-[232px]"
)}>
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden! border-r border-gray-300/20 bg-default p-4 text-white shadow-sm backdrop-blur-lg">
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 overflow-x-hidden! bg-default w-full overflow-scroll border-r border-gray-300/20 p-4 text-white shadow-sm backdrop-blur-lg">
<h3>{t("what")}:</h3>
<p>{booking.title}</p>
<h3>{t("invitee_timezone")}:</h3>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.9.7",
"version": "5.9.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -110,7 +110,7 @@
"memory-cache": "^0.2.0",
"micro": "^10.0.1",
"mime-types": "^2.1.35",
"next": "15.5.4",
"next": "15.5.7",
"next-auth": "^4.22.1",
"next-axiom": "^0.17.0",
"next-i18next": "^15.4.2",
@@ -0,0 +1,153 @@
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
test.describe("Booking Confirmation and Rejection via API", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("should successfully confirm a booking via GET /api/verify-booking-token", async ({ page, users }) => {
const organizer = await users.create({
name: "Test Organizer",
overrideDefaultEventTypes: true,
eventTypes: [
{
title: "30 Min Meeting",
slug: "30-min",
length: 30,
requiresConfirmation: true,
},
],
});
const eventType = organizer.eventTypes[0];
if (!eventType) {
throw new Error("Event type not found");
}
const oneTimePassword = uuidv4();
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${Date.now()}`,
title: "Test Booking",
startTime,
endTime,
status: BookingStatus.PENDING,
oneTimePassword,
user: {
connect: {
id: organizer.id,
},
},
eventType: {
connect: {
id: eventType.id,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Test Attendee",
timeZone: "Europe/London",
},
},
},
});
const url = `/api/verify-booking-token?action=accept&token=${oneTimePassword}&bookingUid=${booking.uid}&userId=${organizer.id}`;
const response = await page.request.get(url, {
maxRedirects: 0,
});
expect(response.status()).toBe(303);
const location = response.headers()["location"];
expect(location).toContain(`/booking/${booking.uid}`);
const updatedBooking = await prisma.booking.findUnique({
where: { id: booking.id },
});
expect(updatedBooking?.status).toBe(BookingStatus.ACCEPTED);
expect(updatedBooking?.oneTimePassword).toBeNull();
});
test("should successfully reject a booking via POST /api/verify-booking-token", async ({ page, users }) => {
const organizer = await users.create({
name: "Test Organizer",
overrideDefaultEventTypes: true,
eventTypes: [
{
title: "30 Min Meeting",
slug: "30-min",
length: 30,
requiresConfirmation: true,
},
],
});
const eventType = organizer.eventTypes[0];
if (!eventType) {
throw new Error("Event type not found");
}
const oneTimePassword = uuidv4();
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${Date.now()}`,
title: "Test Booking",
startTime,
endTime,
status: BookingStatus.PENDING,
oneTimePassword,
user: {
connect: {
id: organizer.id,
},
},
eventType: {
connect: {
id: eventType.id,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Test Attendee",
timeZone: "Europe/London",
},
},
},
});
const url = `/api/verify-booking-token?action=reject&token=${oneTimePassword}&bookingUid=${booking.uid}&userId=${organizer.id}`;
const response = await page.request.post(url, {
data: { reason: "Not available at this time" },
maxRedirects: 0,
});
expect(response.status()).toBe(303);
const location = response.headers()["location"];
expect(location).toContain(`/booking/${booking.uid}`);
const updatedBooking = await prisma.booking.findUnique({
where: { id: booking.id },
});
expect(updatedBooking?.status).toBe(BookingStatus.REJECTED);
expect(updatedBooking?.rejectionReason).toBe("Not available at this time");
expect(updatedBooking?.oneTimePassword).toBeNull();
});
});
+74 -217
View File
@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { addFilter } from "./filter-helpers";
@@ -23,18 +23,8 @@ test.describe("Out of office", () => {
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await goToOOOPage(page);
await openOOODialog(page);
await page.getByTestId("reason_select").click();
@@ -80,18 +70,8 @@ test.describe("Out of office", () => {
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await goToOOOPage(page);
await openOOODialog(page);
await page.getByTestId("reason_select").click();
@@ -167,12 +147,7 @@ test.describe("Out of office", () => {
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await goToOOOPage(page);
// expect table-redirect-toUserId to be visible
await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
@@ -231,20 +206,8 @@ test.describe("Out of office", () => {
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await page.locator('[data-testid="date-range"]').click();
await goToOOOPage(page);
await openOOODialog(page);
await selectToAndFromDates(page, "13", "22", true);
@@ -280,20 +243,8 @@ test.describe("Out of office", () => {
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await page.locator('[data-testid="date-range"]').click();
await goToOOOPage(page);
await openOOODialog(page);
await selectToAndFromDates(page, "13", "22");
@@ -302,11 +253,7 @@ test.describe("Out of office", () => {
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
// add another entry
await entriesListRespPromise;
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await page.locator('[data-testid="date-range"]').click();
await openOOODialog(page);
await selectToAndFromDates(page, "11", "24");
@@ -321,20 +268,8 @@ test.describe("Out of office", () => {
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await page.locator('[data-testid="date-range"]').click();
await goToOOOPage(page);
await openOOODialog(page);
await selectToAndFromDates(page, "13", "22");
@@ -343,11 +278,7 @@ test.describe("Out of office", () => {
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
// add another entry
await entriesListRespPromise;
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await page.locator('[data-testid="date-range"]').click();
await openOOODialog(page);
await selectToAndFromDates(page, "13", "22");
@@ -359,32 +290,16 @@ test.describe("Out of office", () => {
const user = await users.create({ name: "userOne" });
await user.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const addOOOButton = page.getByTestId("add_entry_ooo");
const dateButton = page.locator('[data-testid="date-range"]');
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await addOOOButton.click();
await reasonListRespPromise;
await goToOOOPage(page);
await openOOODialog(page);
//Creates 2 OOO entries:
//First OOO is created on Next month 1st - 3rd
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3");
await expect(page.locator(`data-testid=table-redirect-n-a`).nth(0)).toBeVisible();
//Second OOO is created on Next month 4th - 6th
await entriesListRespPromise;
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await openOOODialog(page);
await selectDateAndCreateOOO(page, "4", "6");
await expect(page.locator(`data-testid=table-redirect-n-a`).nth(1)).toBeVisible();
});
@@ -403,23 +318,10 @@ test.describe("Out of office", () => {
await owner.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const addOOOButton = page.getByTestId("add_entry_ooo");
const dateButton = page.locator('[data-testid="date-range"]');
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await addOOOButton.click();
await reasonListRespPromise;
await goToOOOPage(page);
await openOOODialog(page);
//As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
await expect(
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
@@ -427,12 +329,8 @@ test.describe("Out of office", () => {
//As member1, OOO is created on Next month 4th - 5th, forwarding to 'owner'
await member1User?.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await goToOOOPage(page);
await openOOODialog(page);
await selectDateAndCreateOOO(page, "4", "5", owner.id);
await expect(page.locator(`data-testid=table-redirect-${owner.username ?? "n-a"}`).nth(0)).toBeVisible();
});
@@ -452,22 +350,10 @@ test.describe("Out of office", () => {
await owner.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await goToOOOPage(page);
const addOOOButton = page.getByTestId("add_entry_ooo");
const dateButton = page.locator('[data-testid="date-range"]');
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await test.step("As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'", async () => {
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await openOOODialog(page);
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
await expect(
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
@@ -476,12 +362,8 @@ test.describe("Out of office", () => {
await test.step("As member1, expect error while OOO is created on Next month 4th - 5th, forwarding to 'owner'", async () => {
await member1User?.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await goToOOOPage(page);
await openOOODialog(page);
await selectDateAndCreateOOO(page, "2", "5", owner.id, 400);
await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
});
@@ -505,30 +387,12 @@ test.describe("Out of office", () => {
const member3User = users.get().find((user) => user.name === "member-3");
await teamAdmin.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office?type=team");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const addOOOButton = page.getByTestId("add_entry_ooo");
const dateButton = page.locator('[data-testid="date-range"]');
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
const legacyListMembersRespPromise = page.waitForResponse(
(response) => response.url().includes("legacyListMembers") && response.status() === 200
);
await addOOOButton.click();
await reasonListRespPromise;
await legacyListMembersRespPromise;
await legacyListMembersRespPromise;
await goToOOOPage(page, "team");
await openOOODialog(page);
await test.step("Admin can create OOO for team member and add redirect", async () => {
//OOO is created for 'member-1' on Next month 1st - 3rd, forwarding to 'member-2'
await page.getByTestId(`ooofor_username_select_${member1User?.id}`).click();
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", member2User?.id, 200, true);
await expect(
@@ -538,13 +402,9 @@ test.describe("Out of office", () => {
await test.step("Reverse redirect not allowed for team member", async () => {
//Try to create OOO for 'member-2' on Next month 1st - 3rd, forwarding to 'member-1'
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await legacyListMembersRespPromise;
await legacyListMembersRespPromise;
await openOOODialog(page);
await page.getByTestId(`ooofor_username_select_${member2User?.id}`).click();
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", member1User?.id, 400, true);
expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
await page.locator(`text=${t("cancel")}`).click();
@@ -553,9 +413,6 @@ test.describe("Out of office", () => {
await test.step("Edit OOO and change redirect member", async () => {
//Change redirect member to 'member-3' for OOO created in step 1
await page.getByTestId(`ooo-edit-${member2User?.username}`).click();
await reasonListRespPromise;
await legacyListMembersRespPromise;
await legacyListMembersRespPromise;
await page.getByTestId(`team_username_select_${member3User?.id}`).click();
await saveAndWaitForResponse(page);
@@ -620,12 +477,7 @@ test.describe("Out of office", () => {
await test.step("member3 logins and navigates to team OOO", async () => {
await member3User?.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office?type=team");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await goToOOOPage(page, "team");
//Non-Admin canNot create OOO for team members
await expect(page.locator('[data-testid="add_entry_ooo"]')).toBeDisabled();
@@ -653,12 +505,7 @@ test.describe("Out of office", () => {
test("Default date range filter set to `Last 7 Days`", async ({ page, users }) => {
const user = await users.create({ name: `userOne=${Date.now()}` });
await user.apiLogin();
const responsePromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await responsePromise;
await goToOOOPage(page);
await addFilter(page, "dateRange");
await expect(
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
@@ -668,12 +515,7 @@ test.describe("Out of office", () => {
test("Can choose date range presets", async ({ page, users }) => {
const user = await users.create({ name: `userOne=${Date.now()}` });
await user.apiLogin();
const responsePromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await responsePromise;
await goToOOOPage(page);
await addFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
@@ -697,8 +539,6 @@ test.describe("Out of office", () => {
teammates: teamMatesObj,
}
);
const member1User = users.get().find((user) => user.name === member1Name);
const member2User = users.get().find((user) => user.name === member2Name);
//create OOO for member3, start:currentDate+2Days, end:currentDate+4days (future ooo)
await prisma.outOfOfficeEntry.create({
@@ -717,12 +557,7 @@ test.describe("Out of office", () => {
});
await member3User?.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await entriesListRespPromise;
await page.waitForLoadState("domcontentloaded");
await goToOOOPage(page);
//By Default future OOO will be displayed
//1 OOO record should be visible for member3, end=currentDate+4days
@@ -747,7 +582,6 @@ test.describe("Out of office", () => {
teammates: teamMatesObj,
}
);
const member1User = users.get().find((user) => user.name === member1Name);
const member2User = users.get().find((user) => user.name === member2Name);
//create OOO for member3, start:currentDate-2Days, end:currentDate-4days (for Last 7 Days)
@@ -768,12 +602,7 @@ test.describe("Out of office", () => {
});
await member3User?.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await entriesListRespPromise;
await page.waitForLoadState("domcontentloaded");
await goToOOOPage(page);
//Default filter 'Last 7 Days' when DateRange Filter is selected
await test.step("Default filter - 'Last 7 Days'", async () => {
@@ -809,7 +638,6 @@ test.describe("Out of office", () => {
}
);
const member1User = users.get().find((user) => user.name === member1Name);
const member2User = users.get().find((user) => user.name === member2Name);
//create OOO for member3, start:currentDate-12Days, end:currentDate-10days (for Last 30 Days)
await prisma.outOfOfficeEntry.create({
@@ -829,12 +657,7 @@ test.describe("Out of office", () => {
});
await member3User?.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await entriesListRespPromise;
await page.waitForLoadState("domcontentloaded");
await goToOOOPage(page);
//Select 'Last 30 Days'
await test.step("select 'Last 30 Days'", async () => {
@@ -872,6 +695,9 @@ async function saveAndWaitForResponse(page: Page, expectedStatusCode = 200) {
}
async function selectToAndFromDates(page: Page, fromDate: string, toDate: string, isRangeInPast = false) {
await page.getByTestId("date-range").click();
await page.locator(".rdp").waitFor({ state: "visible" });
const month = isRangeInPast ? "previous" : "next";
await page.locator(`button[name="${month}-month"]`).click();
@@ -891,14 +717,25 @@ async function selectDateAndCreateOOO(
editMode = false
) {
const t = await localize("en");
await page.locator(`button[name="${month}-month"]`).click();
await page.getByTestId("date-range").click();
await page.locator(".rdp").waitFor({ state: "visible" });
// Look for month navigation button
const monthButtonSelector = `button[name="${month}-month"]`;
await page.locator(monthButtonSelector).click();
await page.locator(`button[name="day"]:text-is("${fromDate}")`).nth(0).click();
await page.locator(`button[name="day"]:text-is("${toDate}")`).nth(0).click();
editMode
? await page.locator(`text=${t("edit_an_out_of_office")}`).click()
: forTeamMember
? await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click()
: await page.locator(`text=${t("create_an_out_of_office")}`).click();
if (editMode) {
await page.locator(`text=${t("edit_an_out_of_office")}`).click();
} else if (forTeamMember) {
await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click();
} else {
await page.locator(`text=${t("create_an_out_of_office")}`).click();
}
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
await page.getByTestId("notes_input").click();
@@ -909,3 +746,23 @@ async function selectDateAndCreateOOO(
}
await saveAndWaitForResponse(page, expectedStatusCode);
}
async function goToOOOPage(page: Page, type: "individual" | "team" = "individual") {
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office" + (type === "team" ? "?type=team" : ""));
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
}
async function openOOODialog(page: Page) {
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
const hasTeamPlanRespPromise = page.waitForResponse(
(response) => response.url().includes("hasTeamPlan?batch=1") && response.status() === 200
);
await page.getByTestId("add_entry_ooo").click();
await Promise.all([reasonListRespPromise, hasTeamPlanRespPromise]);
}
+57 -4
View File
@@ -32,6 +32,7 @@
"verify_email_change_success_toast": "Updated your email to {{email}}",
"verify_email_change_failure_toast": "Failed to update email.",
"change_of_email": "Verify your new {{appName}} email address",
"trace_reference_id": "Trace reference ID",
"change_of_email_toast": "We have sent a verification link to {{email}}. We will update your email address once you click this link.",
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
"verify_email_email_body": "Please verify your email address by clicking the button below.",
@@ -308,6 +309,8 @@
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation": "Confirmation",
"cancellation": "Cancellation",
"request": "Request",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username",
"username": "Username",
@@ -401,8 +404,14 @@
"welcome_back": "Welcome back",
"welcome_to_calcom": "Welcome to {{appName}}",
"personal_welcome_description": "You're all set! Start scheduling meetings and managing your calendar.",
"unlimited_calendars": "Unlimited calendars",
"unlimited_event_types": "Unlimited event types",
"workflows_feature": "Workflows",
"integrate_with_favorite_apps": "Integrate with your favorite apps",
"accept_payments_via_stripe": "Accept payments via Stripe",
"html_react_embed": "HTML & React Embed",
"cal_ai_phone_agent": "Cal.ai phone agent",
"cal_video": "Cal Video",
"and_more": "And more…",
"welcome_instructions": "Tell us what to call you and let us know what timezone you're in. You'll be able to edit this later.",
"connect_caldav": "Connect to CalDav (Beta)",
@@ -1365,6 +1374,9 @@
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
"team_impersonation_description": "Allows your team Owners/Admins to temporarily sign in as you.",
"cal_signup_description": "Free for individuals. Team plans for collaborative features.",
"data_region": "Data region",
"united_states": "United States",
"european_union": "European Union",
"make_org_private": "Make organization private",
"make_org_private_description": "Your organization members won't be able to see other organization members when this is turned on.",
"make_team_private": "Make team private",
@@ -2913,8 +2925,8 @@
"email_team_invite|content|added_to_subteam": "{{invitedBy}} has added you to the team <strong>{{teamName}}</strong> in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_team_invite|content|invited_to_subteam": "{{invitedBy}} has invited you to join the team <strong>{{teamName}}</strong> in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email_team_invite|content|invited_to_regular_team": "{{invitedBy}} has invited you to join their team {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
"email|existing_user_added_link_will_change": "On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.<br /><br />Please note: All of your personal event types will be moved into the <strong>{{teamName}}</strong> organization, which may also include potential personal link. <br /><br />For personal events we recommend creating a new account with a personal email address.",
"email|existing_user_added_link_changed": "Your link has been changed from {{a0}} to {{a1}} but don't worry, all previous links still work and redirect appropriately. <br /><br />Please note: All of your personal event types have been moved into the <strong>{{teamName}}</strong> organisation, which may also include potential personal link.<br /><br />Please log in and make sure you have no private events on your new organisational account.<br /><br />For personal events we recommend creating a new account with a personal email address.<br /><br />Enjoy your new clean link: {{a2}}",
"email|existing_user_added_link_will_change": "On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.<br /><br />Please note: All of your personal event types will be moved into the <strong>{{teamName}}</strong> organization, which may also include potential personal links. <br /><br />For personal events we recommend creating a new account with a personal email address.",
"email|existing_user_added_link_changed": "Your link has been changed from {{a0}} to {{a1}} but don't worry, all previous links still work and redirect appropriately. <br /><br />Please note: All of your personal event types have been moved into the <strong>{{teamName}}</strong> organization, which may also include potential personal links.<br /><br />Please log in and make sure you have no private events on your new organizational account.<br /><br />For personal events we recommend creating a new account with a personal email address.<br /><br />Enjoy your new clean link: {{a2}}",
"email_organization_created|subject": "Your organization has been created",
"your_current_plan": "Your current plan",
"organization_price_per_user_month": "$37 per user per month",
@@ -3507,7 +3519,6 @@
"user_workflows_unwhitelisted": "User workflows unwhitelisted",
"date_and_time": "Date & Time",
"email_reminder_upcoming_event_notice": "This is a reminder about your upcoming event.",
"email_reminder_triggered_by_workflow": "This reminder was triggered by a Workflow in Cal.",
"event_upper_case": "Event",
"scheduling_by": "Scheduling by",
"experience_review_prompt": "How was your recent experience?",
@@ -3525,7 +3536,6 @@
"add_verified_emails": "Add verified emails",
"custom_reply_to_email_title": "Custom 'Reply-To' email",
"custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email",
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
"skip_trial": "Skip trial period",
"team_trials_skipped_successfully": "Team trials skipped successfully",
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
@@ -3926,6 +3936,37 @@
"no_description_provided": "No description provided",
"organization_blocklist": "Organization Blocklist",
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
"guest_notifications": "Guest Notifications",
"guest_notifications_description": "Manage guest booking emails across all event types in your organization",
"guest_booking_email_notifications": "Guest Booking Email Notifications",
"guest_booking_email_notifications_description": "Manage which booking emails are sent to guests across all event types in your organization",
"disable_guest_emails_warning": "These settings control important booking emails to keep your guests updated. They apply to all event types in your organization. If you disable emails, we recommend setting up org-wide workflows instead.",
"disable_all_booking_emails_to_guests": "Disable all booking emails to guests",
"disable_all": "Disable All",
"enable_all": "Enable All",
"disable_all_booking_emails_to_guests_description": "When enabled, guests will not receive any booking-related emails. This does not affect Workflow emails.",
"disable_all_guest_booking_emails_confirm_title": "Are you sure you want to disable all booking emails?",
"disable_all_guest_booking_emails_confirm_description": "This will prevent guests from receiving important booking updates across all event types in your organization. We recommend setting up custom org-wide workflow.",
"enable_all_guest_booking_emails_confirm_title": "Are you sure you want to enable all booking emails?",
"enable_all_guest_booking_emails_confirm_description": "This will allow guests to receive all booking-related email notifications across all event types in your organization.",
"disable_individual_guest_email_confirm_title": "Are you sure you want to disable {{emailType}} emails?",
"disable_individual_guest_email_confirm_description": "This will prevent guests from receiving {{emailType}} emails across all event types in your organization. We recommend setting up a custom org-wide Workflow for this.",
"email_type": "Email Type",
"guest_confirmation_email_description": "Email sent when booking is created",
"guest_cancellation_email_description": "Email sent when booking is cancelled",
"guest_rescheduled_email_description": "Email sent when booking is rescheduled",
"guest_request_email_description": "Email sent when booking is requested",
"attendee_request_email_description": "Email sent when booking is requested or declined",
"attendee_reassigned_email_description": "Email sent when host is reassigned for the booking",
"attendee_awaiting_payment_email_description": "Email sent when payment is pending",
"attendee_reschedule_request_email_description": "Email sent when organizer requests attendee to reschedule",
"attendee_location_change_email_description": "Email sent when meeting location is updated",
"attendee_new_event_email_description": "Email sent to existing attendees when new guests are added",
"host_reassignment": "Host Reassignment",
"awaiting_payment": "Awaiting Payment",
"reschedule_request": "Reschedule Request",
"location_change": "Location Change",
"guest_added": "Guest Added",
"invalid_domain_format": "Invalid domain format. Example: example.com",
"invalid_email_address": "Invalid email address. Example: user@example.com",
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
@@ -4052,5 +4093,17 @@
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -9,6 +9,18 @@ import type { EventType, User, WorkflowsOnEventTypes } from "@calcom/prisma/clie
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
// Helper to setup transaction mock that executes the callback with the prisma mock
const setupTransactionMock = () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.$transaction.mockImplementation(async (callback) => {
if (typeof callback === "function") {
return await callback(prismaMock);
}
return Promise.all(callback);
});
};
// create input does not allow ID
const mockFindFirstEventType = (
data?: Partial<EventType> & { workflows?: WorkflowsOnEventTypes[] } & { users?: User[] }
@@ -132,6 +144,28 @@ describe("handleChildrenEventTypes", () => {
metadata: { managedEventConfig: {} },
locations: [],
});
// Setup transaction mock to execute the callback
setupTransactionMock();
// 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,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
@@ -143,25 +177,28 @@ describe("handleChildrenEventTypes", () => {
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...expectedEvType,
parentId: 1,
users: { connect: [{ id: 4 }] },
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
userId: 4,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useBookerTimezone: false,
restrictionScheduleId: null,
allowReschedulingCancelledBookings: false,
},
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
data: [
{
...expectedEvType,
parentId: 1,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
userId: 4,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useBookerTimezone: false,
restrictionScheduleId: null,
allowReschedulingCancelledBookings: false,
},
],
skipDuplicates: true,
select: { id: true, userId: true },
});
expect(result.newUserIds).toEqual([4]);
expect(result.oldUserIds).toEqual([]);
@@ -304,6 +341,29 @@ describe("handleChildrenEventTypes", () => {
metadata: { managedEventConfig: {} },
locations: [],
});
// Setup transaction mock to execute the callback
setupTransactionMock();
// 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,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
@@ -316,27 +376,29 @@ describe("handleChildrenEventTypes", () => {
updatedValues: {},
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...expectedEvType,
parentId: 1,
users: { connect: [{ id: 4 }] },
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
instantMeetingScheduleId: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
userId: 4,
workflows: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useBookerTimezone: false,
restrictionScheduleId: null,
allowReschedulingCancelledBookings: false,
},
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
data: [
{
...expectedEvType,
parentId: 1,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
instantMeetingScheduleId: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
userId: 4,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useBookerTimezone: false,
restrictionScheduleId: null,
allowReschedulingCancelledBookings: false,
},
],
skipDuplicates: true,
select: { id: true, userId: true },
});
expect(result.newUserIds).toEqual([4]);
expect(result.oldUserIds).toEqual([]);
@@ -439,6 +501,31 @@ describe("handleChildrenEventTypes", () => {
],
});
// Setup transaction mock to execute the callback
setupTransactionMock();
// 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,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
// Mock the event type that will be returned for existing users
const mockUpdatedEventType = {
id: 2,
@@ -466,6 +553,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: "" } },
@@ -481,33 +572,42 @@ describe("handleChildrenEventTypes", () => {
});
const { createdAt, updatedAt, ...expectedEvType } = evType;
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...expectedEvType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
useBookerTimezone: false,
restrictionScheduleId: null,
parentId: 1,
userId: 5,
users: {
connect: [{ id: 5 }],
if ("workflows" in expectedEvType) delete expectedEvType.workflows;
// 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,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
useBookerTimezone: false,
restrictionScheduleId: null,
instantMeetingScheduleId: undefined,
parentId: 1,
userId: 5,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
allowReschedulingCancelledBookings: false,
},
workflows: {
create: [{ workflowId: 11 }],
},
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
allowReschedulingCancelledBookings: false,
},
],
skipDuplicates: true,
select: { id: true, userId: true },
});
// Verify workflowsOnEventTypes.createMany was called for new users' workflows
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
data: [{ eventTypeId: 3, workflowId: 11 }],
skipDuplicates: true,
});
const { profileId, rrSegmentQueryValue, createdAt: _, updatedAt: __, ...rest } = evType;
if ("workflows" in rest) delete rest.workflows;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
@@ -531,18 +631,23 @@ describe("handleChildrenEventTypes", () => {
},
},
});
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,
});
});
});
+5
View File
@@ -0,0 +1,5 @@
# Cal.com OAuth Configuration
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
+67
View File
@@ -0,0 +1,67 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
.yarn/
.pnp.*
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
*.log
# macOS
.DS_Store
*.pem
# local env files
.env*.local
.env
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
# WXT Chrome Extension build outputs
.output/
.wxt/
dev/
# Extension builds (generated at build time)
*.zip
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
coverage/
.nyc_output/
# Misc
*.code-workspace
+2
View File
@@ -0,0 +1,2 @@
cd companion
npx lint-staged
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
semi: true,
trailingComma: "es5",
singleQuote: false,
printWidth: 100,
tabWidth: 2,
useTabs: false,
arrowParens: "always",
plugins: ["prettier-plugin-tailwindcss"],
};
+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");
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"expo": {
"name": "expo-wxt-app",
"slug": "calcom-companion",
"scheme": "expo-wxt-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"deploymentTarget": "18.0",
"bundleIdentifier": "com.calcom.companion",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.calcom.companion"
},
"web": {
"favicon": "./assets/favicon.png",
"bundler": "metro"
},
"extra": {
"eas": {
"projectId": "f5e97f6f-1e95-44ac-bfa4-f737ef90f198"
}
},
"owner": "calcoms-organization"
}
}
+100
View File
@@ -0,0 +1,100 @@
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Tabs } from "expo-router";
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
import { Platform } from "react-native";
export default function TabLayout() {
// Check if liquid glass effect is available
const supportsLiquidGlass = isLiquidGlassAvailable();
// Use NativeTabs for iOS 15+ (liquid glass support), fallback to standard Tabs
if (supportsLiquidGlass) {
return (
<NativeTabs>
<NativeTabs.Trigger name="event-types">
<Icon sf="link" />
<Label>Event Types</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="bookings">
<Icon sf="calendar" />
<Label>Bookings</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="availability">
<Icon sf="clock" />
<Label>Availability</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="more">
<Icon sf="ellipsis" />
<Label>More</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
// Fallback to standard Expo Router Tabs for older iOS and Android
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#000000",
tabBarInactiveTintColor: "#8E8E93",
tabBarStyle: {
backgroundColor: "#ffffff",
borderTopWidth: 0.5,
borderTopColor: "#C6C6C8",
paddingBottom: 4,
paddingTop: 4,
height: Platform.OS === "android" ? 74 : 66,
},
}}
>
<Tabs.Screen
name="event-types"
options={{
title: "Event Types",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "link" : "link-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="bookings"
options={{
title: "Bookings",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "calendar" : "calendar-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="availability"
options={{
title: "Availability",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "time" : "time-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="more"
options={{
title: "More",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? "ellipsis-horizontal" : "ellipsis-horizontal-outline"}
size={24}
color={color}
/>
),
}}
/>
</Tabs>
);
}
+685
View File
@@ -0,0 +1,685 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter, useFocusEffect } from "expo-router";
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Text,
FlatList,
ActivityIndicator,
RefreshControl,
TouchableOpacity,
ActionSheetIOS,
Alert,
Platform,
Modal,
TextInput,
KeyboardAvoidingView,
} from "react-native";
import { CalComAPIService, Schedule } from "../../services/calcom";
import { Header } from "../../components/Header";
import { FullScreenModal } from "../../components/FullScreenModal";
export default function Availability() {
const router = useRouter();
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [filteredSchedules, setFilteredSchedules] = useState<Schedule[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [creating, setCreating] = useState(false);
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchSchedules = async () => {
try {
setError(null);
// Fetch all schedules
const allSchedules = await CalComAPIService.getSchedules();
// Sort schedules: default first, then by name
const sortedSchedules = allSchedules.sort((a, b) => {
// Default schedule first
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
// Then sort by name alphabetically
return a.name.localeCompare(b.name);
});
setSchedules(sortedSchedules);
setFilteredSchedules(sortedSchedules);
} catch (err) {
setError("Failed to load availability. Please check your API key and try again.");
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchSchedules();
}, []);
// Refresh schedules when screen comes into focus
useFocusEffect(
useCallback(() => {
// Only refresh if not currently loading (to avoid duplicate calls)
if (!loading && !refreshing) {
fetchSchedules();
}
}, [loading, refreshing])
);
const onRefresh = () => {
setRefreshing(true);
fetchSchedules();
};
const handleSearch = (query: string) => {
setSearchQuery(query);
if (query.trim() === "") {
setFilteredSchedules(schedules);
} else {
const filtered = schedules.filter((schedule) =>
schedule.name.toLowerCase().includes(query.toLowerCase())
);
setFilteredSchedules(filtered);
}
};
const handleScheduleLongPress = (schedule: Schedule) => {
if (Platform.OS !== "ios") {
// Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
const options = [];
if (!schedule.isDefault) {
options.push({ text: "⭐ Set as default", onPress: () => handleSetAsDefault(schedule) });
}
options.push(
{ text: "📋 Duplicate", onPress: () => handleDuplicate(schedule) },
{ text: "🗑️ Delete", style: "destructive", onPress: () => handleDelete(schedule) }
);
// Android Alert automatically adds cancel, so we don't need to include it explicitly
Alert.alert(schedule.name, "", options);
return;
}
const options = ["Cancel"];
if (!schedule.isDefault) {
options.push("⭐ Set as default");
}
options.push("📋 Duplicate", "🗑️ Delete");
const destructiveButtonIndex = options.length - 1; // Delete button
const cancelButtonIndex = 0;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex,
cancelButtonIndex,
title: schedule.name,
},
(buttonIndex) => {
if (buttonIndex === cancelButtonIndex) {
return;
}
if (!schedule.isDefault) {
// Options: ["Cancel", "⭐ Set as default", "📋 Duplicate", "🗑️ Delete"]
if (buttonIndex === 1) {
handleSetAsDefault(schedule);
} else if (buttonIndex === 2) {
handleDuplicate(schedule);
} else if (buttonIndex === 3) {
handleDelete(schedule);
}
} else {
// Options: ["Cancel", "📋 Duplicate", "🗑️ Delete"]
if (buttonIndex === 1) {
handleDuplicate(schedule);
} else if (buttonIndex === 2) {
handleDelete(schedule);
}
}
}
);
};
const handleSetAsDefault = async (schedule: Schedule) => {
try {
await CalComAPIService.updateSchedule(schedule.id, { isDefault: true });
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
}
};
const handleDuplicate = async (schedule: Schedule) => {
try {
await CalComAPIService.duplicateSchedule(schedule.id);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to duplicate schedule. Please try again.");
}
};
const handleDelete = (schedule: Schedule) => {
if (Platform.OS === "web") {
// Use custom modal for web
setShowDeleteModal(true);
} else {
// Use native Alert for iOS/Android
Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await CalComAPIService.deleteSchedule(schedule.id);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
}
},
},
]);
}
};
const confirmDelete = async () => {
if (!selectedSchedule) return;
try {
setDeleting(true);
await CalComAPIService.deleteSchedule(selectedSchedule.id);
setShowDeleteModal(false);
setSelectedSchedule(null);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
setDeleting(false);
}
};
const handleSchedulePress = (schedule: Schedule) => {
router.push({
pathname: "/availability-detail",
params: { id: schedule.id.toString() },
});
};
const handleCreateNew = () => {
setNewScheduleName("");
setShowCreateModal(true);
};
const handleCreateSchedule = async () => {
if (!newScheduleName.trim()) {
Alert.alert("Error", "Please enter a schedule name");
return;
}
try {
setCreating(true);
// Get user's timezone (default to America/New_York if not available)
let userTimezone = "America/New_York";
try {
const userProfile = await CalComAPIService.getUserProfile();
if (userProfile.timeZone) {
userTimezone = userProfile.timeZone;
}
} catch (error) {
console.log("Could not get user timezone, using default");
}
// Create schedule with Monday-Friday 9 AM - 5 PM default
const newSchedule = await CalComAPIService.createSchedule({
name: newScheduleName.trim(),
timeZone: userTimezone,
isDefault: false,
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
});
setShowCreateModal(false);
setNewScheduleName("");
// Navigate to edit the newly created schedule
router.push({
pathname: "/availability-detail",
params: {
id: newSchedule.id.toString(),
},
});
// Refresh the list
fetchSchedules();
} catch (error) {
console.error("Failed to create schedule:", error);
Alert.alert("Error", "Failed to create schedule. Please try again.");
} finally {
setCreating(false);
}
};
const renderSchedule = ({ item: schedule, index }: { item: Schedule; index: number }) => {
return (
<TouchableOpacity
className="border-b border-[#E5E5EA] bg-white active:bg-[#F8F9FA]"
onPress={() => handleSchedulePress(schedule)}
onLongPress={() => handleScheduleLongPress(schedule)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-semibold text-[#333]">{schedule.name}</Text>
{schedule.isDefault && (
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
</View>
{schedule.availability && schedule.availability.length > 0 ? (
<View>
{schedule.availability.map((slot, slotIndex) => (
<View
key={`${schedule.id}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-2" : ""}
>
<Text className="text-sm text-[#666]">
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
</Text>
</View>
))}
</View>
) : (
<Text className="text-sm text-[#666]">No availability set</Text>
)}
<View className="mt-2 flex-row items-center">
<Ionicons name="globe-outline" size={14} color="#666" />
<Text className="ml-1.5 text-sm text-[#666]">{schedule.timeZone}</Text>
</View>
</View>
{/* Three dots button - vertically centered on the right */}
<TouchableOpacity
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
setSelectedSchedule(schedule);
setShowActionsModal(true);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</View>
</TouchableOpacity>
);
};
if (loading) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-[#666]">Loading availability...</Text>
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-[#333]">
Unable to load availability
</Text>
<Text className="mb-6 text-center text-base text-[#666]">{error}</Text>
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={fetchSchedules}>
<Text className="text-base font-semibold text-white">Retry</Text>
</TouchableOpacity>
</View>
</View>
);
}
if (schedules.length === 0 && !loading) {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="calendar-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No schedules found</Text>
<Text className="text-center text-base text-[#666]">
Create your availability schedule in Cal.com
</Text>
</View>
</View>
);
}
if (filteredSchedules.length === 0 && searchQuery.trim() !== "") {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="search-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No results found</Text>
<Text className="text-center text-base text-[#666]">
Try searching with different keywords
</Text>
</View>
</View>
);
}
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={renderSchedule}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
{/* Create Schedule Modal */}
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={() => setShowCreateModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowCreateModal(false)}
>
<TouchableOpacity
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={{
shadowColor: "#000",
shadowOffset: { width: 0, height: 20 },
shadowOpacity: 0.25,
shadowRadius: 25,
elevation: 24,
}}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
</View>
{/* Content */}
<View className="px-8 pb-6">
<View className="mb-1">
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={setNewScheduleName}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowCreateModal(false);
setNewScheduleName("");
}}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${creating ? "opacity-60" : ""}`}
onPress={handleCreateSchedule}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Schedule Actions Modal */}
<FullScreenModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Schedule Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* Set as Default - only show if not already default */}
{selectedSchedule && !selectedSchedule.isDefault && (
<>
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
handleSetAsDefault(selectedSchedule);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="star-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Set as Default</Text>
</TouchableOpacity>
<View className="mx-4 my-2 h-px bg-gray-200" />
</>
)}
{/* Duplicate */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
setTimeout(() => {
handleDuplicate(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="copy-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Duplicate</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Delete */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
// Small delay to ensure modal closes before alert shows
setTimeout(() => {
handleDelete(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Delete</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Delete Confirmation Modal */}
<FullScreenModal
visible={showDeleteModal}
animationType="fade"
onRequestClose={() => !deleting && setShowDeleteModal(false)}
>
<View className="flex-1 items-center justify-center bg-black/50 p-4">
<View className="w-full max-w-sm rounded-2xl bg-white p-6">
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
<Ionicons name="trash-outline" size={24} color="#EF4444" />
</View>
</View>
{/* Title */}
<Text className="mb-2 text-center text-xl font-semibold text-gray-900">
Delete Schedule
</Text>
{/* Description */}
<Text className="mb-6 text-center text-base text-gray-600">
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
undone.
</Text>
{/* Buttons */}
<View className="flex-row gap-3">
<TouchableOpacity
className="flex-1 rounded-lg bg-gray-100 px-4 py-3"
onPress={() => setShowDeleteModal(false)}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-gray-700">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 rounded-lg bg-gray-900 px-4 py-3 ${deleting ? "opacity-50" : ""}`}
onPress={confirmDelete}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-white">Delete</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
</View>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import React from "react";
import { View, Text, TouchableOpacity, ScrollView, Linking, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { Header } from "../../components/Header";
import { LogoutButton } from "../../components/LogoutButton";
interface MoreMenuItem {
name: string;
icon: keyof typeof Ionicons.glyphMap;
isExternal?: boolean;
href?: string;
onPress?: () => void;
}
export default function More() {
const router = useRouter();
const openExternalLink = async (url: string, fallbackMessage: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
}
} catch (error) {
console.error(`Failed to open ${url}:`, error);
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
}
};
const menuItems: MoreMenuItem[] = [
{
name: "Profile",
icon: "person-outline",
isExternal: true,
onPress: () =>
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page"),
},
{
name: "Apps",
icon: "grid-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/apps", "Apps page"),
},
{
name: "Routing",
icon: "git-branch-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/routing", "Routing page"),
},
{
name: "Workflows",
icon: "flash-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/workflows", "Workflows page"),
},
{
name: "Insights",
icon: "bar-chart-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/insights", "Insights page"),
},
{
name: "Support",
icon: "help-circle-outline",
isExternal: true,
onPress: () => openExternalLink("https://go.cal.com/support", "Support"),
},
];
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 90 }}
showsVerticalScrollIndicator={false}
>
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.name}
onPress={item.href ? () => router.push(item.href!) : item.onPress}
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${
index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : ""
}`}
>
<View className="flex-1 flex-row items-center">
<Ionicons name={item.icon} size={20} color="#333" />
<Text className="ml-3 text-base font-semibold text-[#333]">{item.name}</Text>
</View>
{item.isExternal ? (
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
) : (
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
)}
</TouchableOpacity>
))}
</View>
{/* Authentication Info and Logout */}
<View className="py-4">
<LogoutButton className="w-full bg-transparent text-gray-900" />
</View>
<Text className="mt-4 text-sm text-gray-500">
We view the companion as an extension of the web application. If you are performing any
complicated actions, please refer back to the web application.
</Text>
</ScrollView>
</View>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Stack } from "expo-router";
import { Platform, View, StatusBar } from "react-native";
import { AuthProvider, useAuth } from "../contexts/AuthContext";
import LoginScreen from "../components/LoginScreen";
import "../global.css";
function RootLayoutContent() {
const { isAuthenticated } = useAuth();
const content = isAuthenticated ? (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
) : (
<LoginScreen />
);
const containerClass =
Platform.OS === "web"
? "w-[400px] flex-1 flex-col self-end border-l border-gray-200 bg-white"
: "bg-white";
const containerStyle =
Platform.OS === "web"
? {
width: 400,
flex: 1,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}
: { flex: 1 };
return (
<View style={containerStyle} className={containerClass}>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
{content}
</View>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootLayoutContent />
</AuthProvider>
);
}
+994
View File
@@ -0,0 +1,994 @@
import { Ionicons } from "@expo/vector-icons";
import { GlassView } from "expo-glass-effect";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import React, { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
Switch,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, Schedule } from "../services/calcom";
import { ScheduleAvailability } from "../services/types";
import { FullScreenModal } from "../components/FullScreenModal";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Convert 24-hour time to 12-hour format with AM/PM
const formatTime12Hour = (time24: string): string => {
const [hours, minutes] = time24.split(":");
const hour = parseInt(hours, 10);
const min = minutes || "00";
const period = hour >= 12 ? "PM" : "AM";
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
// Pad single-digit hours with leading zero for consistent width
const hour12Padded = hour12.toString().padStart(2, "0");
return `${hour12Padded}:${min} ${period}`;
};
// Format availability for display
const formatAvailabilityDisplay = (
availability: Record<number, ScheduleAvailability[]>
): string[] => {
// Group slots by time range
const timeRangeMap: Record<string, number[]> = {};
Object.keys(availability).forEach((dayIndexStr) => {
const dayIndex = Number(dayIndexStr);
const slots = availability[dayIndex];
if (slots && slots.length > 0) {
slots.forEach((slot) => {
const timeKey = `${slot.startTime}-${slot.endTime}`;
if (!timeRangeMap[timeKey]) {
timeRangeMap[timeKey] = [];
}
timeRangeMap[timeKey].push(dayIndex);
});
}
});
// Format each time range group
const formatted: string[] = [];
Object.keys(timeRangeMap).forEach((timeKey) => {
const days = timeRangeMap[timeKey].sort((a, b) => a - b);
const [startTime, endTime] = timeKey.split("-");
const dayNames = days.map((day) => DAY_ABBREVIATIONS[day]).join(", ");
const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
formatted.push(`${dayNames}, ${timeRange}`);
});
return formatted;
};
// Generate time options (15-minute intervals)
const generateTimeOptions = () => {
const options: string[] = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const h = hour.toString().padStart(2, "0");
const m = minute.toString().padStart(2, "0");
options.push(`${h}:${m}`);
}
}
return options;
};
const TIME_OPTIONS = generateTimeOptions();
export default function AvailabilityDetail() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [scheduleName, setScheduleName] = useState("");
const [timeZone, setTimeZone] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
const [showTimezoneModal, setShowTimezoneModal] = useState(false);
const [showTimePicker, setShowTimePicker] = useState<{
dayIndex: number;
slotIndex: number;
type: "start" | "end";
} | null>(null);
const [overrides, setOverrides] = useState<
Array<{
date: string; // Format: "2024-05-20"
startTime: string; // Format: "12:00" or "00:00" for unavailable
endTime: string; // Format: "14:00" or "00:00" for unavailable
}>
>([]);
const [showOverrideModal, setShowOverrideModal] = useState(false);
const [editingOverride, setEditingOverride] = useState<number | null>(null);
const [overrideDate, setOverrideDate] = useState("");
const [overrideStartTime, setOverrideStartTime] = useState("09:00");
const [overrideEndTime, setOverrideEndTime] = useState("17:00");
const [isUnavailable, setIsUnavailable] = useState(false);
const [showOverrideTimePicker, setShowOverrideTimePicker] = useState<{
type: "start" | "end";
} | null>(null);
// Common timezones
const timezones = [
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Kolkata",
"Australia/Sydney",
"UTC",
];
useEffect(() => {
if (id) {
fetchSchedule();
}
}, [id]);
const fetchSchedule = async () => {
try {
setLoading(true);
const scheduleData = await CalComAPIService.getScheduleById(Number(id));
if (scheduleData) {
setSchedule(scheduleData);
setScheduleName(scheduleData.name || "");
setTimeZone(scheduleData.timeZone || "UTC");
setIsDefault(scheduleData.isDefault || false);
// Convert availability array to day-indexed object
const availabilityMap: Record<number, ScheduleAvailability[]> = {};
// Map day names to numbers
const dayNameToNumber: Record<string, number> = {
Sunday: 0,
Monday: 1,
Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
};
if (scheduleData.availability && Array.isArray(scheduleData.availability)) {
scheduleData.availability.forEach((slot) => {
// Handle both string day names and number day formats
let days: number[] = [];
if (Array.isArray(slot.days)) {
days = slot.days
.map((day) => {
// If it's a day name string (e.g., "Sunday", "Monday")
if (typeof day === "string" && dayNameToNumber[day] !== undefined) {
return dayNameToNumber[day];
}
// If it's a number string (e.g., "0", "1")
if (typeof day === "string") {
const parsed = parseInt(day, 10);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 6) {
return parsed;
}
}
// If it's already a number
if (typeof day === "number" && day >= 0 && day <= 6) {
return day;
}
return null;
})
.filter((day): day is number => day !== null);
}
days.forEach((day) => {
if (!availabilityMap[day]) {
availabilityMap[day] = [];
}
availabilityMap[day].push({
days: [day.toString()],
startTime: slot.startTime || "09:00:00",
endTime: slot.endTime || "17:00:00",
});
});
});
}
setAvailability(availabilityMap);
// Load overrides if they exist
if (scheduleData.overrides && Array.isArray(scheduleData.overrides)) {
const formattedOverrides = scheduleData.overrides.map((override) => ({
date: override.date || "",
startTime: override.startTime || "00:00",
endTime: override.endTime || "00:00",
}));
setOverrides(formattedOverrides);
} else {
setOverrides([]);
}
}
} catch (error) {
console.error("Error fetching schedule:", error);
Alert.alert("Error", "Failed to load schedule. Please try again.");
router.back();
} finally {
setLoading(false);
}
};
const toggleDay = (dayIndex: number) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (newAvailability[dayIndex] && newAvailability[dayIndex].length > 0) {
// Disable day - remove availability
delete newAvailability[dayIndex];
} else {
// Enable day - add default time slot (9 AM - 5 PM)
newAvailability[dayIndex] = [
{
days: [dayIndex.toString()],
startTime: "09:00:00",
endTime: "17:00:00",
},
];
}
return newAvailability;
});
};
const addTimeSlot = (dayIndex: number) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (!newAvailability[dayIndex]) {
newAvailability[dayIndex] = [];
}
newAvailability[dayIndex].push({
days: [dayIndex.toString()],
startTime: "09:00:00",
endTime: "17:00:00",
});
return newAvailability;
});
};
const removeTimeSlot = (dayIndex: number, slotIndex: number) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (newAvailability[dayIndex]) {
newAvailability[dayIndex] = newAvailability[dayIndex].filter((_, i) => i !== slotIndex);
if (newAvailability[dayIndex].length === 0) {
delete newAvailability[dayIndex];
}
}
return newAvailability;
});
};
const updateTimeSlot = (
dayIndex: number,
slotIndex: number,
type: "start" | "end",
time: string
) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (newAvailability[dayIndex] && newAvailability[dayIndex][slotIndex]) {
const slot = { ...newAvailability[dayIndex][slotIndex] };
if (type === "start") {
slot.startTime = `${time}:00`;
} else {
slot.endTime = `${time}:00`;
}
newAvailability[dayIndex][slotIndex] = slot;
}
return newAvailability;
});
};
const handleSave = async () => {
try {
setSaving(true);
// Convert availability object back to array format with day names
const availabilityArray: Array<{
days: string[]; // Day names like "Monday", "Tuesday"
startTime: string; // Format: "09:00"
endTime: string; // Format: "10:00"
}> = [];
Object.keys(availability).forEach((dayIndexStr) => {
const dayIndex = Number(dayIndexStr);
const slots = availability[dayIndex];
if (slots && slots.length > 0) {
slots.forEach((slot) => {
// Convert day number to day name
const dayName = DAYS[dayIndex];
// Format time from "09:00:00" to "09:00" (remove seconds)
const formatTimeForAPI = (time: string): string => {
return time.substring(0, 5); // Take only HH:MM
};
availabilityArray.push({
days: [dayName],
startTime: formatTimeForAPI(slot.startTime),
endTime: formatTimeForAPI(slot.endTime),
});
});
}
});
// Format overrides for API (ensure times are in HH:MM format)
const formattedOverrides = overrides.map((override) => ({
date: override.date,
startTime: override.startTime.substring(0, 5), // Ensure HH:MM format
endTime: override.endTime.substring(0, 5), // Ensure HH:MM format
}));
await CalComAPIService.updateSchedule(Number(id), {
name: scheduleName,
timeZone,
availability: availabilityArray,
isDefault: isDefault,
overrides: formattedOverrides,
});
Alert.alert("Success", "Schedule updated successfully", [
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
Alert.alert("Error", "Failed to update schedule. Please try again.");
} finally {
setSaving(false);
}
};
const handleSetAsDefault = async () => {
try {
await CalComAPIService.updateSchedule(Number(id), {
isDefault: true,
});
setIsDefault(true);
Alert.alert("Success", "Schedule set as default successfully");
} catch (error) {
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
}
};
const handleDelete = () => {
Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await CalComAPIService.deleteSchedule(Number(id));
Alert.alert("Success", "Schedule deleted successfully", [
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
}
},
},
]);
};
const handleAddOverride = () => {
setEditingOverride(null);
setOverrideDate("");
setOverrideStartTime("09:00");
setOverrideEndTime("17:00");
setIsUnavailable(false);
setShowOverrideModal(true);
};
const handleEditOverride = (index: number) => {
const override = overrides[index];
setEditingOverride(index);
setOverrideDate(override.date);
setOverrideStartTime(override.startTime);
setOverrideEndTime(override.endTime);
setIsUnavailable(override.startTime === "00:00" && override.endTime === "00:00");
setShowOverrideModal(true);
};
const handleDeleteOverride = (index: number) => {
Alert.alert("Delete Override", "Are you sure you want to delete this date override?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
setOverrides(overrides.filter((_, i) => i !== index));
},
},
]);
};
const handleSaveOverride = () => {
if (!overrideDate) {
Alert.alert("Error", "Please select a date");
return;
}
const newOverride = {
date: overrideDate,
startTime: isUnavailable ? "00:00" : overrideStartTime,
endTime: isUnavailable ? "00:00" : overrideEndTime,
};
if (editingOverride !== null) {
// Update existing override
const updated = [...overrides];
updated[editingOverride] = newOverride;
setOverrides(updated);
} else {
// Add new override
setOverrides([...overrides, newOverride]);
}
setShowOverrideModal(false);
};
const formatDateForDisplay = (dateStr: string): string => {
if (!dateStr) return "";
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-[#666]">Loading schedule...</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTitle: () => (
<Text style={{ fontSize: 20, fontWeight: "700" }}>Edit Availability</Text>
),
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()} className="ml-4">
<Ionicons name="chevron-back" size={24} color="#000" />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
className={`mr-4 min-w-[60px] items-center rounded-[10px] bg-black px-2 py-2 md:px-4 ${saving ? "opacity-60" : ""}`}
onPress={handleSave}
disabled={saving}
>
<Text className="text-base font-semibold text-white">Save</Text>
</TouchableOpacity>
),
}}
/>
<View className="flex-1">
<ScrollView
className="flex-1 bg-[#f8f9fa]"
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
>
<View className="gap-4">
{/* Schedule Name and Working Hours Display */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-3 text-xl font-bold text-[#333]">{scheduleName}</Text>
{Object.keys(availability).length > 0 ? (
<View>
{formatAvailabilityDisplay(availability).map((line, index) => (
<Text key={index} className="mb-1 text-base text-[#666]">
{line}
</Text>
))}
</View>
) : (
<Text className="text-base text-[#999]">No availability set</Text>
)}
</View>
{/* Availability Schedule */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-4 text-xl font-bold text-[#333]">Availability</Text>
{DAYS.map((day, dayIndex) => {
const daySlots = availability[dayIndex] || [];
const isEnabled = daySlots.length > 0;
const firstSlot = daySlots[0];
return (
<View
key={dayIndex}
className={`mb-3 border-b border-[#E5E5EA] pb-3 ${dayIndex === DAYS.length - 1 ? "mb-0 border-b-0 pb-0" : ""}`}
>
<View className="flex-row items-center">
<View className="flex-1 flex-row items-center">
<Switch
value={isEnabled}
onValueChange={() => toggleDay(dayIndex)}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#fff"
style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }}
/>
<Text
className="ml-1 text-base font-medium text-[#333]"
style={{ width: 40 }}
>
{DAY_ABBREVIATIONS[dayIndex]}
</Text>
{isEnabled && firstSlot && (
<View className="flex-row items-center gap-2">
<TouchableOpacity
onPress={() =>
setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" })
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(firstSlot.startTime)}
</Text>
</TouchableOpacity>
<Text className="text-base text-[#666]">-</Text>
<TouchableOpacity
onPress={() =>
setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" })
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(firstSlot.endTime)}
</Text>
</TouchableOpacity>
</View>
)}
</View>
{isEnabled && (
<TouchableOpacity onPress={() => addTimeSlot(dayIndex)} className="p-1">
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
</TouchableOpacity>
)}
</View>
{isEnabled && daySlots.length > 1 && (
<View className="mt-2" style={{ marginLeft: 108 }}>
{daySlots.slice(1).map((slot, slotIndex) => (
<View key={slotIndex + 1} className="mb-2 flex-row items-center gap-2">
<TouchableOpacity
onPress={() =>
setShowTimePicker({
dayIndex,
slotIndex: slotIndex + 1,
type: "start",
})
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(slot.startTime)}
</Text>
</TouchableOpacity>
<Text className="text-base text-[#666]">-</Text>
<TouchableOpacity
onPress={() =>
setShowTimePicker({
dayIndex,
slotIndex: slotIndex + 1,
type: "end",
})
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(slot.endTime)}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => removeTimeSlot(dayIndex, slotIndex + 1)}
className="p-1"
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</View>
))}
</View>
)}
</View>
);
})}
</View>
{/* Timezone */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-3 text-xl font-bold text-[#333]">Timezone</Text>
<TouchableOpacity
onPress={() => setShowTimezoneModal(true)}
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
>
<Text className="text-base text-[#333]">{timeZone}</Text>
<Ionicons name="chevron-down" size={20} color="#666" />
</TouchableOpacity>
</View>
{/* Date Overrides */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-2 text-xl font-bold text-[#333]">Date overrides</Text>
<Text className="mb-4 text-base text-[#666]">
Add dates when your availability changes from your daily hours.
</Text>
{overrides.length > 0 && (
<View className="mb-4">
{overrides.map((override, index) => (
<View
key={index}
className="mb-2 flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] p-3"
>
<View className="flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
{formatDateForDisplay(override.date)}
</Text>
{override.startTime === "00:00" && override.endTime === "00:00" ? (
<Text className="text-sm text-[#666]">Unavailable (All day)</Text>
) : (
<Text className="text-sm text-[#666]">
{formatTime12Hour(override.startTime + ":00")} -{" "}
{formatTime12Hour(override.endTime + ":00")}
</Text>
)}
</View>
<View className="flex-row items-center gap-2">
<TouchableOpacity onPress={() => handleEditOverride(index)} className="p-2">
<Ionicons name="pencil-outline" size={20} color="#007AFF" />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDeleteOverride(index)}
className="p-2"
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</View>
</View>
))}
</View>
)}
<TouchableOpacity
onPress={handleAddOverride}
className="flex-row items-center justify-center rounded-lg border border-dashed border-[#E5E5EA] py-3"
>
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
<Text className="ml-2 text-base font-medium text-[#007AFF]">Add an override</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* Bottom Action Bar */}
<GlassView
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingTop: 16,
paddingHorizontal: 20,
backgroundColor: "#f8f9fa",
borderTopWidth: 0.5,
borderTopColor: "#E5E5EA",
paddingBottom: insets.bottom + 12,
}}
glassEffectStyle="clear"
>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Text className="text-base font-medium text-[#333]">Set as Default</Text>
<Switch
value={isDefault}
onValueChange={handleSetAsDefault}
disabled={isDefault}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
<View className="flex-row items-center gap-3">
<GlassView
className="overflow-hidden rounded-full bg-[rgba(255,255,255,0.1)]"
glassEffectStyle="clear"
>
<TouchableOpacity
className="h-11 w-11 items-center justify-center"
onPress={handleDelete}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</GlassView>
</View>
</View>
</GlassView>
</View>
{/* Timezone Modal */}
<FullScreenModal
visible={showTimezoneModal}
animationType="fade"
onRequestClose={() => setShowTimezoneModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowTimezoneModal(false)}
>
<TouchableOpacity
className="max-h-[80%] w-[90%] max-w-[500px] rounded-2xl bg-white p-4"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">Select Timezone</Text>
<TouchableOpacity onPress={() => setShowTimezoneModal(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
{timezones.map((tz) => (
<TouchableOpacity
key={tz}
onPress={() => {
setTimeZone(tz);
setShowTimezoneModal(false);
}}
className="border-b border-[#E5E5EA] py-3"
>
<Text className="text-base text-[#333]">{tz}</Text>
</TouchableOpacity>
))}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Time Picker Modal */}
<FullScreenModal
visible={!!showTimePicker}
animationType="fade"
onRequestClose={() => setShowTimePicker(null)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowTimePicker(null)}
>
<TouchableOpacity
className="max-h-[80%] w-[90%] max-w-[500px] rounded-2xl bg-white p-4"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
</Text>
<TouchableOpacity onPress={() => setShowTimePicker(null)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
{TIME_OPTIONS.map((time) => {
const currentTime =
showTimePicker?.type === "start"
? availability[showTimePicker.dayIndex]?.[
showTimePicker.slotIndex
]?.startTime.substring(0, 5)
: availability[showTimePicker?.dayIndex || 0]?.[
showTimePicker?.slotIndex || 0
]?.endTime.substring(0, 5);
const isSelected = currentTime === time;
return (
<TouchableOpacity
key={time}
onPress={() => {
if (showTimePicker) {
updateTimeSlot(
showTimePicker.dayIndex,
showTimePicker.slotIndex,
showTimePicker.type,
time
);
setShowTimePicker(null);
}
}}
className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#E3F2FD]" : ""}`}
>
<Text
className={`text-base ${isSelected ? "font-semibold text-[#007AFF]" : "text-[#333]"}`}
>
{time}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Override Modal */}
<FullScreenModal
visible={showOverrideModal}
animationType="fade"
onRequestClose={() => setShowOverrideModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowOverrideModal(false)}
>
<TouchableOpacity
className="max-h-[90%] w-[90%] max-w-[500px] rounded-2xl bg-white p-6"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">
{editingOverride !== null ? "Edit Override" : "Add Override"}
</Text>
<TouchableOpacity onPress={() => setShowOverrideModal(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
<View className="gap-4">
{/* Date Picker */}
<View>
<Text className="mb-2 text-base font-semibold text-[#333]">
Select the dates to override
</Text>
<TextInput
value={overrideDate}
onChangeText={setOverrideDate}
placeholder="YYYY-MM-DD (e.g., 2024-05-20)"
placeholderTextColor="#999"
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-[#333]"
/>
<Text className="mt-1 text-sm text-[#666]">Enter date in YYYY-MM-DD format</Text>
</View>
{/* Unavailable Toggle */}
<View className="flex-row items-center justify-between">
<Text className="text-base font-semibold text-[#333]">
Mark unavailable (All day)
</Text>
<Switch
value={isUnavailable}
onValueChange={setIsUnavailable}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{/* Time Picker (only if not unavailable) */}
{!isUnavailable && (
<View>
<Text className="mb-3 text-base font-semibold text-[#333]">
Which hours are you free?
</Text>
<View className="flex-row items-center gap-2">
<TouchableOpacity
onPress={() => setShowOverrideTimePicker({ type: "start" })}
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3"
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(overrideStartTime + ":00")}
</Text>
</TouchableOpacity>
<Text className="text-base text-[#666]">-</Text>
<TouchableOpacity
onPress={() => setShowOverrideTimePicker({ type: "end" })}
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3"
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(overrideEndTime + ":00")}
</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Save Button */}
<TouchableOpacity
onPress={handleSaveOverride}
className="mt-4 rounded-lg bg-black py-3"
>
<Text className="text-center text-base font-semibold text-white">
Save Override
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Override Time Picker Modal */}
<FullScreenModal
visible={!!showOverrideTimePicker}
animationType="fade"
onRequestClose={() => setShowOverrideTimePicker(null)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowOverrideTimePicker(null)}
>
<TouchableOpacity
className="max-h-[60%] w-[90%] max-w-[500px] rounded-2xl bg-white p-6"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">
Select {showOverrideTimePicker?.type === "start" ? "Start" : "End"} Time
</Text>
<TouchableOpacity onPress={() => setShowOverrideTimePicker(null)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
{TIME_OPTIONS.map((time) => {
const currentTime =
showOverrideTimePicker?.type === "start" ? overrideStartTime : overrideEndTime;
const isSelected = currentTime === time;
return (
<TouchableOpacity
key={time}
onPress={() => {
if (showOverrideTimePicker?.type === "start") {
setOverrideStartTime(time);
} else {
setOverrideEndTime(time);
}
setShowOverrideTimePicker(null);
}}
className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#F0F9FF]" : ""}`}
>
<Text
className={`text-base ${isSelected ? "font-semibold text-[#007AFF]" : "text-[#333]"}`}
>
{formatTime12Hour(time + ":00")}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</>
);
}
+747
View File
@@ -0,0 +1,747 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import React, { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Linking,
TextInput,
Platform,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, Booking } from "../services/calcom";
import { SvgImage } from "../components/SvgImage";
import { FullScreenModal } from "../components/FullScreenModal";
import { getAppIconUrl } from "../utils/getAppIconUrl";
import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations";
// Format date: "Tuesday, November 25, 2025"
const formatDateFull = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
} catch (error) {
return "";
}
};
// Format time: "9:40pm - 10:00pm"
const formatTime12Hour = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "pm" : "am";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const minStr = minutes.toString().padStart(2, "0");
return `${hour12}:${minStr}${period}`;
} catch (error) {
return "";
}
};
// Get timezone from date string
const getTimezone = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone;
} catch (error) {
return "";
}
};
// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
const getInitials = (name: string): string => {
if (!name) return "";
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return "";
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
// Get first letter of first name and first letter of last name
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
};
// Get location provider info
const getLocationProvider = (location: string | undefined, metadata?: Record<string, any>) => {
// Check metadata for videoCallUrl first
const videoCallUrl = metadata?.videoCallUrl;
const locationToCheck = videoCallUrl || location;
if (!locationToCheck) return null;
// Check if it's a video call URL
if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
// Try to detect provider from URL
if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
const iconUrl = getAppIconUrl("daily_video", "cal-video");
return {
label: "Cal Video",
iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
url: locationToCheck,
};
}
// Check for other video providers by URL pattern
const videoProviders = [
{ pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
{
pattern: /meet\.google\.com/,
label: "Google Meet",
type: "google_video",
appId: "google-meet",
},
{
pattern: /teams\.microsoft\.com/,
label: "Microsoft Teams",
type: "office365_video",
appId: "msteams",
},
];
for (const provider of videoProviders) {
if (provider.pattern.test(locationToCheck)) {
const iconUrl = getAppIconUrl(provider.type, provider.appId);
return {
label: provider.label,
iconUrl: iconUrl,
url: locationToCheck,
};
}
}
// Generic link meeting
const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
return {
label: "Link Meeting",
iconUrl: linkIconUrl,
url: locationToCheck,
};
}
// Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
const appId = locationToCheck.replace("integrations:", "");
const iconUrl = getAppIconUrl("", appId);
if (iconUrl) {
// Format appId to display name (e.g., "cal-video" -> "Cal Video")
const formatAppIdToDisplayName = (id: string): string => {
return id
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
return {
label: formatAppIdToDisplayName(appId),
iconUrl: iconUrl,
url: null,
};
}
}
// Check if it's a default location type
const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
if (defaultLocation) {
return {
label: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
url: null,
};
}
// Fallback: return as plain text location
return {
label: locationToCheck,
iconUrl: null,
url: null,
};
};
export default function BookingDetail() {
const router = useRouter();
const { uid } = useLocalSearchParams<{ uid: string }>();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [showActionsModal, setShowActionsModal] = useState(false);
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
const [rescheduleReason, setRescheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
useEffect(() => {
if (uid) {
fetchBooking();
}
}, [uid]);
const fetchBooking = async () => {
try {
setLoading(true);
setError(null);
const bookingData = await CalComAPIService.getBookingByUid(uid);
console.log("Booking data received:", JSON.stringify(bookingData, null, 2));
console.log("User:", bookingData.user);
console.log("Hosts:", bookingData.hosts);
console.log("Attendees:", bookingData.attendees);
console.log("Recurring fields:", {
recurringEventId: bookingData.recurringEventId,
recurringBookingUid: (bookingData as any).recurringBookingUid,
metadata: (bookingData as any).metadata,
});
setBooking(bookingData);
} catch (err) {
console.error("Error fetching booking:", err);
setError("Failed to load booking. Please try again.");
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} finally {
setLoading(false);
}
};
const handleJoinMeeting = () => {
if (!booking?.location) return;
const provider = getLocationProvider(booking.location);
if (provider?.url) {
Linking.openURL(provider.url);
}
};
const handleReschedule = async () => {
if (!booking) return;
setRescheduling(true);
try {
// For now, we'll use the current start time
// In a full implementation, you'd show a date/time picker
await CalComAPIService.rescheduleBooking(booking.uid, {
start: booking.startTime,
reschedulingReason: rescheduleReason.trim() || undefined,
});
Alert.alert("Success", "Reschedule request sent successfully");
setShowRescheduleModal(false);
setRescheduleReason("");
// Refresh booking data
await fetchBooking();
} catch (error) {
console.error("Failed to reschedule booking:", error);
Alert.alert("Error", "Failed to send reschedule request. Please try again.");
} finally {
setRescheduling(false);
}
};
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
);
}
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
<TouchableOpacity
className="mt-6 rounded-lg bg-black px-6 py-3"
onPress={() => router.back()}
>
<Text className="text-base font-semibold text-white">Go Back</Text>
</TouchableOpacity>
</View>
);
}
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
const timezone = getTimezone(startTime);
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTitle: booking?.title || "Booking Details",
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
className="items-center justify-center rounded-full"
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
>
<Ionicons name="chevron-back" size={24} color="#000" />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => setShowActionsModal(true)}
className="items-center justify-center rounded-full"
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
>
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</TouchableOpacity>
),
}}
/>
<View className="flex-1 bg-[#f8f9fa]">
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, paddingBottom: 100 }}>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
<Text className="text-base text-[#666]">
{dateFormatted} {timeFormatted} ({timezone})
</Text>
</View>
{/* Who Section */}
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
{/* Show host from user field or hosts array */}
{(booking.user || (booking.hosts && booking.hosts.length > 0)) && (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(booking.user.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{booking.user.name}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
</View>
</View>
) : booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, hostIndex) => (
<View
key={hostIndex}
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(host.name || "Host")}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{host.name || "Host"}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
</View>
</View>
))
) : null}
</View>
)}
{booking.attendees && booking.attendees.length > 0 && (
<View>
{booking.attendees.map((attendee, index) => (
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(attendee.name)}
</Text>
</View>
<View className="flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
{attendee.name}
</Text>
<Text className="text-sm text-[#666]">{attendee.email}</Text>
</View>
</View>
))}
</View>
)}
</View>
{/* Where Section */}
{locationProvider && (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
{locationProvider.url ? (
<TouchableOpacity
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
{locationProvider.iconUrl && (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
</Text>
</TouchableOpacity>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl && (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
)}
{/* Recurring Event Section */}
{(booking.recurringEventId || booking.recurringBookingUid) && (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
</View>
)}
{/* Description Section */}
{booking.description && (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
</View>
)}
{/* Join Meeting Button */}
{locationProvider?.url && (
<TouchableOpacity
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl && (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</TouchableOpacity>
)}
</ScrollView>
</View>
{/* Booking Actions Modal */}
<FullScreenModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Booking Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* View Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Navigate to booking view page
console.log("View booking");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="eye-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">View Booking</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Edit event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
</View>
{/* Request Reschedule */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
setShowRescheduleModal(true);
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="send-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Send Reschedule Request</Text>
</TouchableOpacity>
{/* Edit Location */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open edit location dialog
console.log("Edit location");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="location-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Edit Location</Text>
</TouchableOpacity>
{/* Add Guests */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open add guests dialog
console.log("Add guests");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="person-add-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Add Guests</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* After event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">After event</Text>
</View>
{/* View Recordings */}
{locationProvider?.url && (
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open view recordings dialog
console.log("View recordings");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="videocam-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">View Recordings</Text>
</TouchableOpacity>
)}
{/* Meeting Session Details */}
{locationProvider?.url && (
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open session details dialog
console.log("Meeting session details");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="information-circle-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Meeting Session Details</Text>
</TouchableOpacity>
)}
{/* Mark as No-Show */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Mark as no-show
console.log("Mark as no-show");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="eye-off-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Mark as No-Show</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Report Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open report booking dialog
console.log("Report booking");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="flag-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Report Booking</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Cancel Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel",
style: "destructive",
onPress: () => {
// TODO: Cancel booking
console.log("Cancel booking");
},
},
]);
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="close-circle-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Cancel Booking</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Reschedule Modal */}
<FullScreenModal
visible={showRescheduleModal}
animationType="fade"
onRequestClose={() => setShowRescheduleModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowRescheduleModal(false)}
>
<TouchableOpacity
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={{
shadowColor: "#000",
shadowOffset: { width: 0, height: 20 },
shadowOpacity: 0.25,
shadowRadius: 25,
elevation: 24,
}}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<View className="flex-row items-start space-x-3">
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
<Ionicons name="time-outline" size={24} color="#111827" />
</View>
<View className="flex-1 pt-1">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Reschedule request
</Text>
<Text className="text-sm text-[#6B7280]">
Send a reschedule request to the organizer of this booking.
</Text>
</View>
</View>
</View>
{/* Content */}
<View className="px-8 pb-6">
<Text className="mb-2 text-sm font-bold text-[#111827]">
Reason for reschedule request
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
</Text>
<TextInput
className="min-h-[100px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="Please let us know why you need to reschedule..."
placeholderTextColor="#9CA3AF"
value={rescheduleReason}
onChangeText={setRescheduleReason}
multiline
numberOfLines={4}
textAlignVertical="top"
editable={!rescheduling}
/>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowRescheduleModal(false);
setRescheduleReason("");
}}
disabled={rescheduling}
>
<Text className="text-base font-medium text-[#374151]">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${rescheduling ? "opacity-60" : ""}`}
onPress={handleReschedule}
disabled={rescheduling}
>
<Text className="text-base font-medium text-white">Reschedule request</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
// Constants for Event Type Detail screen
export const bufferTimeOptions = [
"No buffer time",
"5 Minutes",
"10 Minutes",
"15 Minutes",
"20 Minutes",
"30 Minutes",
"45 Minutes",
"60 Minutes",
"90 Minutes",
"120 Minutes",
];
export const timeUnitOptions = ["Minutes", "Hours", "Days"];
export const frequencyUnitOptions = ["Per day", "Per Month", "Per year"];
export const durationUnitOptions = ["Per day", "Per week", "Per month"];
export const slotIntervalOptions = [
"Default",
"5 Minutes",
"10 Minutes",
"15 Minutes",
"20 Minutes",
"30 Minutes",
"45 Minutes",
"60 Minutes",
"75 Minutes",
"90 Minutes",
"105 Minutes",
"120 Minutes",
];
export const availableDurations = [
"5 mins",
"10 mins",
"15 mins",
"20 mins",
"25 mins",
"30 mins",
"45 mins",
"50 mins",
"60 mins",
"75 mins",
"80 mins",
"90 mins",
"120 mins",
"150 mins",
"180 mins",
"240 mins",
"300 mins",
"360 mins",
"420 mins",
"480 mins",
];
export const defaultLocations = [
{ label: "Attendee Address", type: "attendeeInPerson" as const },
{ label: "Organizer Address", type: "inPerson" as const },
{ label: "Link Meeting", type: "link" as const },
{ label: "Attendee Phone Number", type: "phone" as const },
{ label: "Organizer Phone Number", type: "userPhone" as const },
];
export const tabs = [
{ id: "basics", label: "Basics" },
{ id: "availability", label: "Availability" },
{ id: "limits", label: "Limits" },
{ id: "advanced", label: "Advanced" },
{ id: "recurring", label: "Recurring" },
{ id: "apps", label: "Apps" },
{ id: "webhooks", label: "Webhooks" },
{ id: "private-links", label: "Private Links" },
{ id: "seats", label: "Seats" },
{ id: "colors", label: "Colors" },
];
@@ -0,0 +1,521 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface AdvancedTabProps {
// Calendar event name
calendarEventName: string;
setCalendarEventName: (value: string) => void;
// Add to calendar email
addToCalendarEmail: string;
setAddToCalendarEmail: (value: string) => void;
// Layout
selectedLayouts: string[];
setSelectedLayouts: (layouts: string[]) => void;
defaultLayout: string;
setDefaultLayout: (layout: string) => void;
// Confirmation settings
requiresConfirmation: boolean;
setRequiresConfirmation: (value: boolean) => void;
disableCancelling: boolean;
setDisableCancelling: (value: boolean) => void;
disableRescheduling: boolean;
setDisableRescheduling: (value: boolean) => void;
// Additional settings
sendCalVideoTranscription: boolean;
setSendCalVideoTranscription: (value: boolean) => void;
autoTranslate: boolean;
setAutoTranslate: (value: boolean) => void;
requiresBookerEmailVerification: boolean;
setRequiresBookerEmailVerification: (value: boolean) => void;
hideCalendarNotes: boolean;
setHideCalendarNotes: (value: boolean) => void;
hideCalendarEventDetails: boolean;
setHideCalendarEventDetails: (value: boolean) => void;
hideOrganizerEmail: boolean;
setHideOrganizerEmail: (value: boolean) => void;
lockTimezone: boolean;
setLockTimezone: (value: boolean) => void;
allowReschedulingPastEvents: boolean;
setAllowReschedulingPastEvents: (value: boolean) => void;
allowBookingThroughRescheduleLink: boolean;
setAllowBookingThroughRescheduleLink: (value: boolean) => void;
// Redirect on booking
successRedirectUrl: string;
setSuccessRedirectUrl: (value: string) => void;
forwardParamsSuccessRedirect: boolean;
setForwardParamsSuccessRedirect: (value: boolean) => void;
// Custom reply-to email
customReplyToEmail: string;
setCustomReplyToEmail: (value: string) => void;
// Event type colors
eventTypeColorLight: string;
setEventTypeColorLight: (value: string) => void;
eventTypeColorDark: string;
setEventTypeColorDark: (value: string) => void;
}
export function AdvancedTab(props: AdvancedTabProps) {
const layoutOptions = [
{ id: "MONTH_VIEW", label: "Month", icon: "calendar-outline" },
{ id: "WEEK_VIEW", label: "Weekly", icon: "calendar-outline" },
{ id: "COLUMN_VIEW", label: "Column", icon: "list-outline" },
];
return (
<View className="gap-3">
{/* Calendar Event Name Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Calendar event name</Text>
<TextInput
className="mb-2 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.calendarEventName}
onChangeText={props.setCalendarEventName}
placeholder="30min between Pro Example and {Scheduler}"
placeholderTextColor="#8E8E93"
/>
<Text className="text-xs text-[#666]">
Use variables like {"{"}Scheduler{"}"} for booker name, {"{"}Organizer{"}"} for your name
</Text>
</View>
{/* Add to Calendar Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Add to calendar</Text>
<Text className="mb-3 text-sm text-[#666]">
We'll display this email address as the organizer, and send confirmation emails here.
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.addToCalendarEmail}
onChangeText={props.setAddToCalendarEmail}
placeholder="pro@example.com"
placeholderTextColor="#8E8E93"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* Layout Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Layout</Text>
<Text className="mb-3 text-sm text-[#666]">
You can select multiple and your bookers can switch views.
</Text>
{/* Layout Options */}
<View className="mb-4 gap-2">
{layoutOptions.map((layout) => (
<TouchableOpacity
key={layout.id}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
props.selectedLayouts.includes(layout.id)
? "border-black bg-[#F0F0F0]"
: "border-[#E5E5EA]"
}`}
onPress={() => {
if (props.selectedLayouts.includes(layout.id)) {
// Don't allow deselecting if it's the only one
if (props.selectedLayouts.length > 1) {
props.setSelectedLayouts(props.selectedLayouts.filter((l) => l !== layout.id));
// If removing the default, set a new default
if (props.defaultLayout === layout.id) {
const remaining = props.selectedLayouts.filter((l) => l !== layout.id);
props.setDefaultLayout(remaining[0]);
}
}
} else {
props.setSelectedLayouts([...props.selectedLayouts, layout.id]);
}
}}
>
<View className="flex-row items-center gap-2">
<Ionicons name={layout.icon as any} size={20} color="#333" />
<Text className="text-base text-[#333]">{layout.label}</Text>
</View>
{props.selectedLayouts.includes(layout.id) && (
<Ionicons name="checkmark-circle" size={20} color="#000" />
)}
</TouchableOpacity>
))}
</View>
{/* Default View */}
{props.selectedLayouts.length > 1 && (
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Default view</Text>
<View className="gap-2">
{props.selectedLayouts.map((layoutId) => {
const layout = layoutOptions.find((l) => l.id === layoutId);
if (!layout) return null;
return (
<TouchableOpacity
key={layout.id}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
props.defaultLayout === layout.id
? "border-black bg-[#F0F0F0]"
: "border-[#E5E5EA]"
}`}
onPress={() => props.setDefaultLayout(layout.id)}
>
<Text className="text-base text-[#333]">{layout.label}</Text>
{props.defaultLayout === layout.id && (
<Ionicons name="checkmark-circle" size={20} color="#000" />
)}
</TouchableOpacity>
);
})}
</View>
</View>
)}
</View>
{/* Booking Questions Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Booking questions</Text>
<Text className="mb-3 text-sm text-[#666]">
Customize the questions asked on the booking page.
</Text>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
onPress={() =>
Alert.alert("Coming Soon", "Booking questions customization will be available soon.")
}
>
<Ionicons name="add-circle-outline" size={20} color="#666" />
<Text className="ml-2 text-base text-[#666]">Manage booking questions</Text>
</TouchableOpacity>
</View>
{/* Requires confirmation */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Requires confirmation</Text>
<Text className="text-sm text-[#666]">
The booking needs to be manually confirmed before it is pushed to your calendar
</Text>
</View>
<Switch
value={props.requiresConfirmation}
onValueChange={props.setRequiresConfirmation}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Disable Cancelling */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Disable Cancelling</Text>
<Text className="text-sm text-[#666]">
Guests can no longer cancel the event with calendar invite or email
</Text>
</View>
<Switch
value={props.disableCancelling}
onValueChange={props.setDisableCancelling}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Disable Rescheduling */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Disable Rescheduling</Text>
<Text className="text-sm text-[#666]">
Guests can no longer reschedule the event with calendar invite or email
</Text>
</View>
<Switch
value={props.disableRescheduling}
onValueChange={props.setDisableRescheduling}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Send Cal Video Transcription Emails */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Send Cal Video Transcription Emails
</Text>
<Text className="text-sm text-[#666]">
Send emails with the transcription of the Cal Video after the meeting ends
</Text>
</View>
<Switch
value={props.sendCalVideoTranscription}
onValueChange={props.setSendCalVideoTranscription}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Auto translate */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Auto translate title and description
</Text>
<Text className="text-sm text-[#666]">
Automatically translate titles and descriptions to the visitor's browser language
using AI
</Text>
</View>
<Switch
value={props.autoTranslate}
onValueChange={props.setAutoTranslate}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Requires booker email verification */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Requires booker email verification
</Text>
<Text className="text-sm text-[#666]">
To ensure booker's email verification before scheduling events
</Text>
</View>
<Switch
value={props.requiresBookerEmailVerification}
onValueChange={props.setRequiresBookerEmailVerification}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide notes in calendar */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide notes in calendar</Text>
<Text className="text-sm text-[#666]">
For privacy reasons, additional inputs and notes will be hidden in the calendar entry
</Text>
</View>
<Switch
value={props.hideCalendarNotes}
onValueChange={props.setHideCalendarNotes}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide calendar event details */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Hide calendar event details on shared calendars
</Text>
<Text className="text-sm text-[#666]">
When a calendar is shared, events are visible but details are hidden from those
without write access
</Text>
</View>
<Switch
value={props.hideCalendarEventDetails}
onValueChange={props.setHideCalendarEventDetails}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide organizer's email */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide organizer's email</Text>
<Text className="text-sm text-[#666]">
Hide organizer's email address from the booking screen, email notifications, and
calendar events
</Text>
</View>
<Switch
value={props.hideOrganizerEmail}
onValueChange={props.setHideOrganizerEmail}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Lock timezone */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Lock timezone on booking page
</Text>
<Text className="text-sm text-[#666]">
To lock the timezone on booking page, useful for in-person events
</Text>
</View>
<Switch
value={props.lockTimezone}
onValueChange={props.setLockTimezone}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Allow rescheduling past events */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Allow rescheduling past events
</Text>
<Text className="text-sm text-[#666]">
Enabling this option allows for past events to be rescheduled
</Text>
</View>
<Switch
value={props.allowReschedulingPastEvents}
onValueChange={props.setAllowReschedulingPastEvents}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Allow booking through reschedule link */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Allow booking through reschedule link
</Text>
<Text className="text-sm text-[#666]">
When enabled, users will be able to create a new booking when trying to reschedule a
cancelled booking
</Text>
</View>
<Switch
value={props.allowBookingThroughRescheduleLink}
onValueChange={props.setAllowBookingThroughRescheduleLink}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Redirect on booking Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Redirect on booking</Text>
<Text className="mb-3 text-sm text-[#666]">
Redirect to a custom URL after a successful booking
</Text>
<TextInput
className="mb-3 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.successRedirectUrl}
onChangeText={props.setSuccessRedirectUrl}
placeholder="https://example.com/thank-you"
placeholderTextColor="#8E8E93"
keyboardType="url"
autoCapitalize="none"
/>
<View className="flex-row items-center justify-between">
<Text className="text-sm text-[#333]">Forward query parameters</Text>
<Switch
value={props.forwardParamsSuccessRedirect}
onValueChange={props.setForwardParamsSuccessRedirect}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Custom Reply-To Email Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Custom 'Reply-To' email</Text>
<Text className="mb-3 text-sm text-[#666]">
Use a different email address as the replyTo for confirmation emails instead of the
organizer's email
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.customReplyToEmail}
onChangeText={props.setCustomReplyToEmail}
placeholder="reply@example.com"
placeholderTextColor="#8E8E93"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* Event Type Color Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Event type color</Text>
<Text className="mb-3 text-sm text-[#666]">
This is only used for event type & booking differentiation within the app. It is not
displayed to bookers.
</Text>
<View className="gap-3">
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Light theme color</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{ backgroundColor: props.eventTypeColorLight }}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorLight}
onChangeText={props.setEventTypeColorLight}
placeholder="#292929"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Dark theme color</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{ backgroundColor: props.eventTypeColorDark }}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorDark}
onChangeText={props.setEventTypeColorDark}
placeholder="#FAFAFA"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
</View>
</View>
</View>
);
}

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