Compare commits

...
Author SHA1 Message Date
github-actions[bot] 8e481f2921 chore: version packages (#24451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-19 08:33:07 -03:00
Dhairyashil ShindeandAnik Dhabal Babu 0e6203ac3c fix: install paypal button team level (#24442)
* fix: install paypal button at team level

* fix: install paypal button at team level

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-19 01:18:31 +05:30
Hariom BalharaandDevin AI 624d6d7001 Add embed-iframe import to error pages (#24475)
Import @calcom/embed-core/src/embed-iframe in both the error page component
and _error.tsx to ensure embed functionality is initialized when users
encounter 500 errors. This allows proper communication with parent frames
when the error page is displayed in an embedded context.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-18 15:39:32 -03:00
an_ifrah24andKeith Williams ec03ab632e fix: make vapid optional to prevent api failure at initialisation (#24469)
* fix: added optional check for VAPID keys

* fix: added extra check for vapid to prevent runtime error

* fix: added consistent logger across the file

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-10-18 18:30:12 +00:00
spandev c81b8145b5 fixed spacing between bold and italic buttons in description of new event type (#24542) 2025-10-18 18:20:57 +00:00
fa35cc5210 chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* NITS

* Add the logic to auto complete admin org when billing enabled

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-10-18 14:13:30 +00:00
Volnei MunhozandDevin AI 1eb6ff3e65 chore: calendar cache optmize (#24533)
* wip

* fix: regenerate Prisma types and fix GoogleCalendarSubscriptionAdapter tests

- Run yarn prisma generate to regenerate Prisma client with missing types (WorkflowType enum)
- Fix addMonthsFromNow helper to properly return the calculated date
- Update GoogleCalendarSubscriptionAdapter to use startOf/endOf day for time ranges
- Update test expectations to use Date objects instead of dayjs objects
- Fix all-day event test to use future date instead of today
- All tests now passing (3599 passed)

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

* fix tests

* parametrize months ahead

* improve names

* Improve based on reviews

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-17 17:20:08 +00:00
fc4ecf500a fix: Apply organization brand colors and theme to member personal events (#24456)
* fix: Apply organization brand colors and theme to member personal events

- Fetch organization brand colors and theme in getEventTypesFromDB
- Override user brand colors/theme with org values for personal events
- Apply to booking confirmation pages, user booking pages, and routing forms
- Follow same pattern as hideBranding for consistency
- Fixes issue where org brand colors only applied to team events

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: Resolve type errors for organization brand color and theme overrides

- Added brandColor, darkBrandColor, theme to ProfileRepository findById organization select
- Updated test mock to include new organization fields
- Ensures consistent organization branding types across all queries
- All type errors related to brand color changes now resolved

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: Remove redundant profile assignment causing type errors

The spread operator already includes profile when it exists.
Explicit assignment causes type conflicts with union types where
profile is not guaranteed to exist in all branches.

Resolves TypeScript error at bookings-single-view.getServerSideProps.tsx:134

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: Use eventTypeRaw.profile for organization brand color access

Changed eventType.profile to eventTypeRaw.profile in profile object construction
to fix TypeScript errors where profile property doesn't exist on transformed
eventType object. The eventTypeRaw object maintains the original profile field
from the database query.

Resolves TypeScript errors at lines 155, 158, 161 in
bookings-single-view.getServerSideProps.tsx

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix type error

* refactor: Extract branding logic into getBrandingForEventType utility

- Create getBrandingForEventType utility function that handles organization
  brand color overrides for both team and personal events
- For team events: org branding → team branding → null
- For personal events: org branding → user branding → null
- Refactor bookings-single-view to use utility function
- Update team booking page to apply organization brand overrides
- Add organization branding fields to ProfileRepository and team queries

Addresses review feedback from @hariombalhara on PR #24456

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Update getBranding.ts

* Change

Set themeBasis to null instead of branding.theme.

* refactor: Simplify branding fallback logic to prevent mixing org/team settings

- Changed getBrandingForEventType to use either parent or team branding entirely
- Simplified team parent branding structure in getServerSideProps
- Prevents inconsistent branding when some properties exist in parent but not others
- Addresses PR review feedback from hariombalhara

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

* addressed review

* test: Add comprehensive unit tests for branding utility functions

- Add 20 unit tests covering getBrandingForEventType, getBrandingForUser, and getBrandingForTeam
- Test organization branding override scenarios for both team and personal events
- Test fallback behavior when organization branding is not set
- Test handling of null and optional branding properties
- Verify theme and color inheritance from parent organizations

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: Simplify branding tests to focus on core scenarios

- Reduce from 20 to 8 essential test cases
- Focus on org override and fallback behavior for each utility
- Remove redundant edge case tests while maintaining full coverage

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
2025-10-17 14:03:50 -03:00
Dhairyashil Shinde 9d4522825b chore: fix-vertical-space-between-name-toggle (#24527) 2025-10-17 18:22:46 +05:30
Benny Joo e91bf53d80 refactor: Remove circular deps between @calcom/lib and @calcom/features [2] (#24438)
* move SystemField to features

* migrate workflow service

* merge two tests for team repository

* update imports and migrate team repository

* migrate delegation credential repository

* migrate credential repository

* migrate entityPermissionUtils

* migrate hashedLink service and repository

* migrate membership service

* update imports

* remove file

* migrate buildEventUrlFromBooking

* migrate getAllUserBookings to features

* update imports

* update organizationMock

* migrate slots

* migrate date-ranges to schedules dir

* migrate getAggregatedAvailability

* fix

* refactor

* migrate useCreateEventType hook to features

* migrate assignValueToUser

* migrate validateUsername to auth features

* migrate system field back to lib

* migrate getLabelValueMapFromResponses back to lib

* update imports

* use relative path

* fix type checks

* fix

* fix

* fix tests

* update gh codeowners

* fix

* fix
2025-10-17 06:48:08 -03:00
emrysal 79b319c1dd chore: release v5.8.1
Post release / release (push) Canceled after 0s
2025-10-17 09:36:29 +00:00
Eunjae Lee 9197a73b31 chore: update style of Routed to per period chart on /insights/routing (#24501)
* chore: update style of Routed to per period chart on /insights/routing

* w to max-w

* remove unused variables
2025-10-17 10:44:52 +02:00
Rajiv Sahal ec3656e646 fix: add isDryRun behaviour for date overrides (#24464)
* chore: add `isDryRun` prop for date overrides

* chore: add changesets

* chore: implement PR feedback

* fix: merge conflicts
2025-10-17 08:42:25 +00:00
sean-brydon ae4b807500 PBAC - README guide (#24517) 2025-10-16 22:34:39 +00:00
Hariom BalharaandDevin AI 34a9872df1 fix: enable format on save with Prettier in VS Code (#24502)
- Set editor.formatOnSave to true
- Set Prettier as the default formatter
- ESLint continues to handle code quality via codeActionsOnSave
- Prettier handles all formatting with eslint-config-prettier disabling conflicting rules

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-16 12:56:02 +00:00
kusiewiczandchauhan_s 91a480b83a fix: managed user api playground (time format transform) (#24255)
* fix: managed user time format transform

* feat: add transform decorator to handle null/undefined timeFormat values in UpdateManagedUserInput

---------

Co-authored-by: chauhan_s <somaychauhan98@gmail.com>
2025-10-16 08:42:34 +00:00
Dhairyashil ShindeandAnik Dhabal Babu db19bb3d51 feat: add booking uid to zapier payload (#24489)
* feat: add booking uid to zapier payload

* resolve type check error

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-16 10:07:49 +02:00
chauhan_sandAnik Dhabal Babu 9afdb1ad7e fix: clear videoCallData and iCalUID when organizer changes in round-robin reassignment (#24474)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-16 09:18:47 +02:00
Joe Au-Yeung 78c2a69389 Script to reassign attributes (#24496) 2025-10-16 07:06:53 +00:00
Yadong (Adam) Zhang 5a483e75c9 fix(apps/web): update description formatting in bookings single view to preserve whitespace (#24440)
- Changed the class of the description paragraph from `break-words` to `break-words whitespace-pre-line` to ensure that whitespace is preserved in the booking description display.
2025-10-16 06:53:47 +00:00
bb9c4bb8f3 feat: Update HubSpot meetings to CANCELLED status instead of archiving on booking cancellation (#24478)
* feat: Update HubSpot meetings to CANCELLED status instead of archiving

When a booking is cancelled in Cal.com, preserve the meeting record
in HubSpot by updating it to a CANCELLED status rather than archiving
it. This maintains the meeting history for better tracking and reporting.

Changes:
- Renamed hubspotDeleteMeeting to hubspotCancelMeeting
- Updated the method to set hs_meeting_outcome to CANCELLED
- Changed from archive() to update() API call
- Removed unused CustomPublicObjectInput interface

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

* fix: Remove return statement to match void return type in deleteEvent

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

* refactor: Remove hs_timestamp from cancel meeting to keep it minimal

Only update the meeting outcome status when cancelling,
without modifying the timestamp field.

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

* fix: Use correct HubSpot API value for canceled meetings

Changed hs_meeting_outcome from "CANCELLED" to "CANCELED" to match
HubSpot API specification. HubSpot uses lowercase American spelling
"CANCELED" as the valid enum value for canceled meeting outcomes.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-16 03:38:10 +00:00
Anik Dhabal Babu 02fdc1361c perf: redundant query in eventsAppsTab (#24491) 2025-10-16 03:12:31 +00:00
sean-brydon 573132198e Fix pbac being enabled when a team has ANY features (#24494) 2025-10-15 21:04:43 +00:00
Anik Dhabal Babu 9133bc5b16 test: fixed flaky e2e tests (#24490)
* test: fixed flaky e2e tests

* fix
2025-10-15 16:19:57 -03:00
Syed Ali Shahbaz 94ae3a5a73 init] (#24487) 2025-10-15 16:09:21 +00:00
Rajiv Sahal 0757b00db7 feat: list schedules atom (#24205)
* feat: add api v2 endpoint to fetch all user schedules

* chore: update typing

* feat: custom hook to fetch all user schedules

* refactor: shift logic to transform schedules into function of its own

* init: list schedules atom

* feat: api endpoints for list schedules atom

* refactor: accept redirect url as prop

* fix: pass redirect url prop

* integrate list schedules atom with availability settings

* refactor: extract types to be reused in api v2 endpoints

* skip availability settings page for the time being until we have api v2 endpoints in prod

* feat: add docs for list schedules atom

* fixup

* export Schedule type

* make sure we always have a default schedule if user deletes his default schedule

* chore: implement code rabbit feedback

* chore: implement PR feedback

* fix: resolve merge conflicts

* fix: import path

* update platform libraries

* fix: type check

* resolve  merge conflicts

* update atoms export

* update platform libraries schedule

* chore: arrange atoms in alphabetical order

* update atoms controller to include endpoints for list schedules atom

* add create atom scheule atom

* fix: invalidate schedules on new schedule creation

* chore: add changesets
2025-10-15 17:57:22 +02:00
264 changed files with 7015 additions and 2805 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
This PR adds ability to display bookings of a user event for calendar view atom
+4 -4
View File
@@ -24,10 +24,10 @@
/packages/embeds/**/* @calcom/Foundation
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
/packages/lib/getUserAvailability.ts @calcom/Foundation
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation
/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation
/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation
/packages/features/schedules/lib/slots.ts @calcom/Foundation
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
+2 -1
View File
@@ -1,6 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
View File
@@ -1,10 +1,10 @@
import type { NextApiRequest } from "next";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -25,6 +25,11 @@ import {
import { z } from "zod";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type {
GetAvailabilityListHandlerReturn,
DuplicateScheduleHandlerReturn,
} from "@calcom/platform-libraries/schedules";
import { getAvailabilityListHandler, duplicateScheduleHandler } from "@calcom/platform-libraries/schedules";
import type { CreateScheduleHandlerReturn, CreateScheduleSchema } from "@calcom/platform-libraries/schedules";
import { createScheduleHandler } from "@calcom/platform-libraries/schedules";
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
@@ -68,6 +73,21 @@ export class AtomsSchedulesController {
};
}
@Get("/schedules/all")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Permissions([SCHEDULE_READ])
async getAllUserSchedules(
@GetUser() user: UserWithProfile
): Promise<ApiResponse<Awaited<GetAvailabilityListHandlerReturn>>> {
const userSchedules = await getAvailabilityListHandler({ ctx: { user } });
return {
status: SUCCESS_STATUS,
data: userSchedules,
};
}
@Patch("schedules/:scheduleId")
@Permissions([SCHEDULE_WRITE])
@UseGuards(ApiAuthGuard)
@@ -104,4 +124,20 @@ export class AtomsSchedulesController {
data: createdSchedule,
};
}
@Post("schedules/:scheduleId/duplicate")
@Permissions([SCHEDULE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Duplicate existing schedule" })
async duplicateExistingSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId", ParseIntPipe) scheduleId: number
): Promise<ApiResponse<Awaited<DuplicateScheduleHandlerReturn>>> {
const duplicatedSchedule = await duplicateScheduleHandler({ ctx: { user }, input: { scheduleId } });
return {
status: SUCCESS_STATUS,
data: duplicatedSchedule,
};
}
}
@@ -1,8 +1,8 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
import { Transform, Type } from "class-transformer";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject, IsNumber } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
@@ -17,6 +17,8 @@ export class CreateManagedUserInput {
@ApiProperty({ example: "Alice Smith", description: "Managed user's name is used in emails" })
name!: string;
@Type(() => Number)
@IsNumber()
@IsOptional()
@IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" })
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" })
@@ -2,6 +2,7 @@ import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
@@ -17,6 +18,8 @@ export class UpdateManagedUserInput {
@ApiPropertyOptional()
name?: string;
@Type(() => Number)
@IsNumber()
@IsOptional()
@IsIn([12, 24])
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
@@ -13,7 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -9,6 +9,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData";
import { MembershipRole } from "@calcom/prisma/enums";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
@@ -56,15 +57,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
// This is because the data is cached and as a result the data is converted to a string
const availabilities = {
...cachedAvailabilities,
schedules: cachedAvailabilities.schedules.map((schedule) => ({
...schedule,
availability: schedule.availability.map((avail) => ({
...avail,
startTime: new Date(avail.startTime),
endTime: new Date(avail.endTime),
date: avail.date ? new Date(avail.date) : null,
})),
})),
schedules: cachedAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)),
};
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -53,16 +53,23 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
const [features, rolePermissions, organizationPermissions] = await Promise.all([
getTeamFeatures(orgId),
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
]);
const features = await getTeamFeatures(orgId);
if (features) {
teamFeatures = {
[orgId]: features,
};
}
// Check if PBAC feature is enabled
const isPbacEnabled = features?.pbac === true;
if (isPbacEnabled) {
// Only fetch and apply PBAC permissions if the feature is enabled
const [rolePermissions, organizationPermissions] = await Promise.all([
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
]);
// Check if user has permission to read roles
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
@@ -71,7 +78,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
} else {
canViewRoles = false;
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
canViewRoles = features ? isOrgAdminOrOwner : false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
}
@@ -0,0 +1,22 @@
import { _generateMetadata } from "app/_utils";
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("resume_onboarding"),
(t) => t("resume_onboarding_description"),
undefined,
undefined,
"/settings/organizations/new/resume"
);
const ServerPage = async () => {
return (
<LayoutWrapper>
<ResumeOnboardingPage />
</LayoutWrapper>
);
};
export default ServerPage;
+2 -2
View File
@@ -6,11 +6,11 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
@@ -12,7 +12,7 @@ import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delega
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import type { Ensure } from "@calcom/types/utils";
+20 -5
View File
@@ -160,13 +160,21 @@ export const AppPage = ({
function refactorMeWithoutEffect() {
const data = appDbQuery.data;
const credentialsCount = data?.credentials.length || 0;
setExistingCredentials(data?.credentials || []);
const credentials = data?.credentials || [];
setExistingCredentials(credentials);
const hasPersonalInstall = credentials.some((c) => !!c.userId && !c.teamId);
const installedTeamIds = new Set<number>();
for (const cred of credentials) {
if (cred.teamId) installedTeamIds.add(cred.teamId);
}
const totalInstalledTargets = (hasPersonalInstall ? 1 : 0) + installedTeamIds.size;
const appInstalledForAllTargets =
availableForTeams && data?.userAdminTeams && data.userAdminTeams.length > 0
? credentialsCount >= data.userAdminTeams.length
: credentialsCount > 0;
? totalInstalledTargets >= data.userAdminTeams.length + 1
: credentials.length > 0;
setAppInstalledForAllTargets(appInstalledForAllTargets);
},
[appDbQuery.data, availableForTeams]
@@ -244,7 +252,14 @@ export const AppPage = ({
loading: isLoading,
};
}
return <InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />;
return (
<InstallAppButtonChild
credentials={availableForTeams ? undefined : appDbQuery.data?.credentials}
paid={paid}
{...props}
/>
);
}}
/>
);
+1
View File
@@ -2,6 +2,7 @@
import React from "react";
import "@calcom/embed-core/src/embed-iframe";
import { HttpError } from "@calcom/lib/http-error";
import { Button } from "@calcom/ui/components/button";
@@ -83,9 +83,12 @@ export const getServerSideProps = async function getServerSideProps(
props: {
isEmbed,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
theme: formWithUserProfile.user.profile?.organization?.theme ?? formWithUserProfile.user.theme,
brandColor:
formWithUserProfile.user.profile?.organization?.brandColor ?? formWithUserProfile.user.brandColor,
darkBrandColor:
formWithUserProfile.user.profile?.organization?.darkBrandColor ??
formWithUserProfile.user.darkBrandColor,
},
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
},
+15 -3
View File
@@ -49,6 +49,13 @@ export const getEventTypesFromDB = async (id: number) => {
profile: {
select: {
organizationId: true,
organization: {
select: {
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
},
teamId: true,
@@ -71,9 +78,15 @@ export const getEventTypesFromDB = async (id: number) => {
slug: true,
name: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
parent: {
select: {
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
createdByOAuthClientId: true,
@@ -116,12 +129,11 @@ export const getEventTypesFromDB = async (id: number) => {
}
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
const isOrgTeamEvent = !!eventType?.team && !!eventType.profile?.organizationId;
return {
isDynamic: false,
...restEventType,
...eventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
metadata,
};
@@ -8,12 +8,12 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
import {
shouldHideBrandingForTeamEvent,
shouldHideBrandingForUserEvent,
} from "@calcom/features/profile/lib/hideBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
+1 -1
View File
@@ -1,4 +1,4 @@
import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization";
import { organizationScenarios } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
import { describe, it, expect, beforeEach, vi } from "vitest";
@@ -4,11 +4,11 @@ import { URLSearchParams } from "url";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildEventUrlFromBooking } from "@calcom/features/bookings/lib/buildEventUrlFromBooking";
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
const querySchema = z.object({
@@ -7,8 +7,9 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
@@ -30,7 +31,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { req, params, query } = context;
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
@@ -133,6 +134,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
const branding = getBrandingForEventType({
eventType: {
team: team.parent ?? team,
users: [],
profile: null,
},
});
return {
props: {
useApiV2,
@@ -153,6 +162,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
: getPlaceholderAvatar(team.logoUrl, team.name),
name,
username: orgSlug ?? null,
...branding,
},
title: eventData.title,
users: eventHostsUserData,
@@ -204,6 +214,9 @@ const getTeamWithEventsData = async (
bannerUrl: true,
logoUrl: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
@@ -214,6 +227,9 @@ const getTeamWithEventsData = async (
logoUrl: true,
name: true,
slug: true,
brandColor: true,
darkBrandColor: true,
theme: true,
eventTypes: {
where: {
slug: meetingSlug,
@@ -141,6 +141,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
<ul className="divide-subtle divide-y" data-testid="schedules" ref={animationParentRef}>
{availabilities.schedules.map((schedule) => (
<ScheduleListItem
redirectUrl={`/availability/${schedule.id}`}
displayOptions={{
hour12: user?.timeFormat ? user.timeFormat === 12 : undefined,
timeZone: user?.timeZone,
@@ -6,12 +6,13 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import prisma from "@calcom/prisma";
import { customInputSchema } from "@calcom/prisma/zod-utils";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
@@ -115,7 +116,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
eventTypeRaw.users = eventTypeRaw.hosts?.length
? eventTypeRaw.hosts.map((host) => host.user)
: eventTypeRaw.users;
@@ -150,9 +151,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor || null,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
...getBrandingForEventType({ eventType: eventTypeRaw }),
slug: eventType.team?.slug || eventType.users[0]?.username || null,
};
@@ -24,9 +24,13 @@ import {
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks";
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
import {
SMS_REMINDER_NUMBER_FIELD,
SystemField,
TITLE_FIELD,
} from "@calcom/lib/bookings/SystemField";
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/SystemField";
import { APP_NAME } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
@@ -694,7 +698,7 @@ export default function Success(props: PageProps) {
<>
<div className="mt-9 font-medium">{t("additional_notes")}</div>
<div className="col-span-2 mb-2 mt-9">
<p className="break-words">{bookingInfo.description}</p>
<p className="whitespace-pre-line break-words">{bookingInfo.description}</p>
</div>
</>
)}
@@ -767,7 +771,7 @@ export default function Success(props: PageProps) {
<Fragment key={field.name}>
<div
className="text-emphasis mt-4 font-medium"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(label),
}}
@@ -1080,7 +1084,7 @@ export default function Success(props: PageProps) {
<span className="underline">
<a
target="_blank"
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations">
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations" rel="noreferrer">
{t("resolve")}
</a>
</span>
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
data-testid={`team.${index}.name`}
label=""
addOnClassname="bg-transparent p-0 border-l-0"
className={index > 0 ? "mb-2" : ""}
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
const session = useSession();
const utils = trpc.useUtils();
const [serverErrorMessage, setServerErrorMessage] = useState("");
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
onSuccess: (data) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
}
},
onError: (error) => {
setServerErrorMessage(t(error.message));
},
});
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
// Single mutation for all flows (billing, self-hosted, admin)
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
onSuccess: async (data) => {
if (data.organization) {
// Invalidate the organizations query to ensure fresh data on the next page
reset({
onboardingId: data.organizationOnboardingId,
});
if (data.checkoutUrl) {
// Billing enabled - redirect to Stripe
window.location.href = data.checkoutUrl;
} else if (data.organizationId) {
// Self-hosted - org already created, redirect to organizations
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
reset();
window.location.href = `${window.location.origin}/settings/organizations/profile`;
} else {
// Unexpected state
setServerErrorMessage("Unexpected response from server");
}
},
onError: (error) => {
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
},
});
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
return {
mutation: mutationToUse,
mutate: mutationToUse.mutate,
isPending: mutationToUse.isPending,
mutation: intentToCreateOrgMutation,
mutate: intentToCreateOrgMutation.mutate,
isPending: intentToCreateOrgMutation.isPending,
errorMessage: serverErrorMessage,
};
};
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
invitedMembers,
logo,
bio,
onboardingId,
name,
slug,
billingPeriod,
seats,
pricePerSeat,
brandColor,
bannerUrl,
} = useOnboardingStore();
const orgCreation = useOrgCreation();
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
placeholder="colleague@company.com"
/>
</div>
<Button type="submit" StartIcon="plus" color="secondary">
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
{t("add")}
</Button>
</form>
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
)}
</div>
<div className="mt-3 mt-6 flex items-center justify-end">
<div className="mt-3 flex items-center justify-end">
<Button
data-testid="publish-button"
onClick={() => {
if (!onboardingId) {
console.error("Org owner email and onboardingId are required", {
orgOwnerEmail,
onboardingId,
});
// Submit ALL data to intentToCreateOrg
if (!name || !slug || !orgOwnerEmail) {
console.error("Required fields missing", { name, slug, orgOwnerEmail });
showToast(t("required_fields_missing"), "error");
return;
}
orgCreation.mutation.mutate({
orgCreation.mutate({
name,
slug,
orgOwnerEmail,
seats,
pricePerSeat,
billingPeriod,
creationSource: "WEBAPP" as const,
logo,
bio,
brandColor,
bannerUrl,
teams,
invitedMembers,
onboardingId,
});
}}
loading={orgCreation.isPending}>
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon } from "@calcom/ui/components/icon";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
const PaymentStatusView = () => {
const { t } = useLocale();
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
const searchParams = useSearchParams();
const paymentStatus = searchParams?.get("paymentStatus");
const paymentError = searchParams?.get("error");
const { useOnboardingStore } = useOnboarding({
step: "status",
});
const { useOnboardingStore } = useOnboarding();
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
const { name } = useOnboardingStore();
@@ -0,0 +1,114 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
import { WizardLayout } from "@calcom/ui/components/layout";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={5}>
{children}
</WizardLayout>
);
};
const ResumeOnboardingView = () => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const onboardingIdParam = searchParams?.get("onboardingId");
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
useEffect(() => {
if (isLoadingOrgOnboarding) {
return;
}
if (!onboardingIdParam) {
router.push("/settings/organizations/new");
return;
}
if (!dbOnboarding) {
return;
}
if (dbOnboarding.isComplete) {
router.push("/settings/organizations");
return;
}
// Load onboarding data into store
reset({
onboardingId: dbOnboarding.id,
name: dbOnboarding.name,
slug: dbOnboarding.slug,
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
billingPeriod: dbOnboarding.billingPeriod,
seats: dbOnboarding.seats,
pricePerSeat: dbOnboarding.pricePerSeat,
logo: dbOnboarding.logo,
bio: dbOnboarding.bio,
brandColor: dbOnboarding.brandColor,
bannerUrl: dbOnboarding.bannerUrl,
});
// Redirect to next step (About page)
router.push("/settings/organizations/new/about");
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
if (isLoadingOrgOnboarding) {
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
<SkeletonText className="h-4 w-1/2" />
</SkeletonContainer>
);
}
if (!onboardingIdParam) {
return (
<Alert
data-testid="error"
severity="error"
title={t("error")}
message={t("no_onboarding_id_provided")}
/>
);
}
if (!dbOnboarding) {
return (
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
);
}
if (dbOnboarding.isComplete) {
return (
<Alert
data-testid="error"
severity="info"
title={t("onboarding_already_complete")}
message={t("onboarding_already_complete_description")}
/>
);
}
// Loading state while redirecting
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
</SkeletonContainer>
);
};
export default ResumeOnboardingView;
@@ -4,8 +4,8 @@ import { useRouter } from "next/navigation";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm";
import { useCreateEventType } from "@calcom/features/eventtypes/hooks/useCreateEventType";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
+1 -1
View File
@@ -60,7 +60,7 @@ vi.mock("@calcom/features/bookings/components/event-meta/Price", () => {
return {};
});
vi.mock("@calcom/lib/SystemField", () => {
vi.mock("@calcom/lib/bookings/SystemField", () => {
return {};
});
@@ -20,7 +20,14 @@ function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof
theme: "dark",
brandColor: "red",
darkBrandColor: "black",
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
organization: {
requestedSlug: "slug",
slug: "slug",
id: 1,
brandColor: null,
darkBrandColor: null,
theme: null,
},
allowSEOIndexing: true,
username: "john",
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.8.0",
"version": "5.8.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1
View File
@@ -7,6 +7,7 @@ import type { ErrorProps } from "next/error";
import NextError from "next/error";
import React from "react";
import "@calcom/embed-core/src/embed-iframe";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
+10 -8
View File
@@ -541,9 +541,10 @@ test.describe("Reschedule for booking with seats", () => {
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
orderBy: { id: "asc" },
const secondAttendeeId = bookingAttendees.find((attendee)=> attendee.email === "second+seats@cal.com");
const references = await prisma.bookingSeat.findFirst({
where: { bookingId: booking.id, attendeeId: secondAttendeeId?.id },
});
const secondUser = await users.create({
@@ -556,13 +557,14 @@ test.describe("Reschedule for booking with seats", () => {
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
const href = await page.locator('[data-testid="reschedule"]').getAttribute("href");
expect(href).toBeTruthy();
const url = new URL(href!, page.url());
const seatReferenceUid = url.searchParams.get('seatReferenceUid');
expect(seatReferenceUid).toBeTruthy();
expect(seatReferenceUid).toBe(references?.referenceUid);
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const rescheduleUid = url.searchParams.get("rescheduleUid");
return !!rescheduleUid && rescheduleUid === references[1].referenceUid;
});
await selectFirstAvailableTimeSlotNextMonth(page);
const nameElement = page.locator("input[name=name]");
+2 -1
View File
@@ -117,6 +117,7 @@ export function createAppsFixture(page: Page) {
},
goToAppsTab: async () => {
await page.getByTestId("vertical-tab-apps").click();
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page");
},
activeApp: async (app: string) => {
await page.locator(`[data-testid='${app}-app-switch']`).click();
@@ -126,7 +127,7 @@ export function createAppsFixture(page: Page) {
},
verifyAppsInfoNew: async (app: string, eventTypeId: number) => {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); // fix the race condition
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page"); // fix the race condition
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
},
};
@@ -0,0 +1,451 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
/**
* Utility function to complete the organization onboarding wizard steps
* (about, add-teams, onboard-members)
*/
async function completeOnboardingWizard(
page: Page,
options: {
aboutText?: string;
teams?: string[];
members?: string[];
} = {}
) {
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
// Step 1: Complete "About" step
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill(aboutText);
await page.locator("button[type=submit]").click();
// Step 2: Complete "Add Teams" step
await page.waitForURL("**/settings/organizations/new/add-teams");
for (let i = 0; i < teams.length; i++) {
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
if (i < teams.length - 1) {
await page.getByTestId("add_a_team").click();
}
}
await page.locator("button[type=submit]").click();
// Step 3: Complete "Onboard Members" step
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Add members if provided
for (const memberEmail of members) {
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
}
// Click publish and wait for response
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.getByTestId("publish-button").click();
const response = await responsePromise;
const responseData = await response.json();
// TRPC batch response is an array with one element
const result = responseData[0].result.data.json;
return result;
}
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
test.describe("Admin Handover Flow (Billing Disabled)", () => {
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
// Setup: Create admin and future owner
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "orgowner",
domain: "example.com",
});
const ownerUser = await users.create({
username: "orgowner",
email: ownerEmail,
});
const orgName = "Test Organization";
const orgSlug = `test-org-${uuid()}`.toLowerCase();
// Step 1: Admin fills form
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
// Submit and wait for intentToCreateOrg
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
// Step 2: Verify admin is on handover page
await page.waitForURL("**/settings/organizations/new/handover");
const onboardingUrlElement = page.getByTestId("onboarding-url");
await expect(onboardingUrlElement).toBeVisible();
const onboardingUrl = await onboardingUrlElement.textContent();
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
// Extract onboardingId from URL
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
expect(onboardingIdMatch).toBeTruthy();
const onboardingId = onboardingIdMatch?.[1];
// Step 3: Switch to owner user
await page.context().clearCookies();
await ownerUser.apiLogin();
// Step 4: Owner opens resume URL and completes the onboarding wizard
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
const result = await completeOnboardingWizard(page, {
aboutText: "This is our test organization",
teams: ["Engineering", "Marketing"],
members: [memberEmail],
});
// Verify the organization was created successfully in the API response
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const orgName = "Admin Org";
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Fill form with admin's own email
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("20");
}
// Submit - admin creating for self goes through wizard
await page.locator("button[type=submit]").click();
// Admin creating for self goes through the onboarding wizard
const result = await completeOnboardingWizard(page, {
aboutText: "Admin's organization",
teams: ["Operations"],
members: [],
});
// Verify the organization was created successfully
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin handover URL structure is correct", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "testowner",
domain: "example.com",
});
await users.create({
username: "testowner",
email: ownerEmail,
});
const orgName = "Handover Test";
const orgSlug = `handover-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.locator("button[type=submit]").click();
// Wait for the response
await responsePromise;
// Wait for redirect to handover page
await page.waitForURL("**/settings/organizations/new/handover");
// Wait for handover page elements to be visible (with longer timeout for store to populate)
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
// Verify URL format
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
// Verify copy button is clickable (toast message may vary by locale)
await page.getByTestId("copy-onboarding-url").click();
});
});
test.describe("Non-Admin User Flow", () => {
test("Regular user creates org through full wizard", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "regularuser",
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
});
await user.apiLogin();
const orgName = "User Organization";
const orgSlug = `user-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Verify user email is pre-filled and disabled
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify billing fields are NOT visible to regular users
if (IS_TEAM_BILLING_ENABLED) {
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
// Verify "Upgrade to Organizations" UI is shown
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
}
// Fill form
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Verify navigated to /about step
await page.waitForURL("**/settings/organizations/new/about");
// Complete about step
await page.locator('textarea[name="about"]').fill("User's organization description");
await page.locator("button[type=submit]").click();
// Complete add-teams step
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team Alpha");
await page.locator("button[type=submit]").click();
// Complete onboard-members step
await page.waitForURL("**/settings/organizations/new/onboard-members");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.getByTestId("publish-button").click(),
]);
// In billing enabled mode, should redirect to Stripe or payment
// In billing disabled mode, org created immediately
if (IS_TEAM_BILLING_ENABLED) {
// Would redirect to Stripe checkout
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
} else {
// After org creation, session update causes redirect to profile page
// Wait for it to load, then navigate to organizations
await page.waitForURL("**/settings/my-account/profile");
await page.goto("/settings/organizations");
await page.waitForURL(/\/settings\/organizations/);
}
});
test("Regular user - email field is disabled", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "testuser",
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Verify email field state
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeVisible();
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify name and slug are derived from email domain
const nameInput = page.locator("input[name=name]");
const slugInput = page.locator("input[name=slug]");
// Name should be capitalized version of domain
const nameValue = await nameInput.inputValue();
expect(nameValue).toBeTruthy();
expect(nameValue).toMatch(/company/i);
// Slug should be domain-based
const slugValue = await slugInput.inputValue();
expect(slugValue).toBeTruthy();
expect(slugValue).toContain("company");
});
});
test.describe("Form Validation", () => {
test("Admin form - required fields validation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Submit empty form
await page.locator("button[type=submit]").click();
// Should show validation errors
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
});
test("Slug already taken shows error", async ({ page, users, orgs }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
// Create an existing org with a specific slug
const existingSlug = `existing-${uuid()}`.toLowerCase();
await orgs.create({
name: "Existing Org",
slug: existingSlug,
});
await page.goto("/settings/organizations/new");
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
await users.create({ username: "owner", email: ownerEmail });
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill("New Org");
await page.locator("input[name=slug]").fill(existingSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("10");
}
await page.locator("button[type=submit]").click();
// Should show slug taken error
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
});
});
test.describe("Navigation Between Steps", () => {
test("User can navigate through wizard steps", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "wizarduser",
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
const orgName = "Wizard Test";
const orgSlug = `wizard-${uuid()}`.toLowerCase();
// Step 1
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Step 2: About
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill("Wizard test org");
await page.locator("button[type=submit]").click();
// Step 3: Add teams
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team 1");
await page.locator("button[type=submit]").click();
// Step 4: Onboard members
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Verify wizard shows correct step numbers
// The WizardLayout component shows currentStep/maxSteps
// Just verify we reached the last step
await expect(page.getByTestId("publish-button")).toBeVisible();
});
});
test.describe("Billing Field Visibility", () => {
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Verify billing fields are visible for admin
await expect(page.locator("input[name=seats]")).toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
await expect(page.locator("#billingPeriod")).toBeVisible();
// Verify toggle between monthly/annual
await expect(page.getByText("Monthly")).toBeVisible();
await expect(page.getByText("Annually")).toBeVisible();
});
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "pricinguser",
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Should NOT see admin billing fields
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
await expect(page.locator("#billingPeriod")).not.toBeVisible();
// Should see upgrade/pricing UI
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
await expect(page.getByText(/organization/i)).toBeVisible();
});
});
});
@@ -1,475 +0,0 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { Messages } from "mailhog";
import path from "path";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import type { createEmailsFixture } from "../fixtures/emails";
import { test } from "../lib/fixtures";
import { fillStripeTestCheckout } from "../lib/testUtils";
import { getEmailsReceivedByUser } from "../lib/testUtils";
async function expectEmailWithSubject(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
const allEmails = (receivedEmails as Messages).items;
const email = allEmails.find((email) => email.subject === subject);
if (!email) {
throw new Error(`Email with subject ${subject} not found`);
}
const dom = new JSDOM(email.html);
return dom;
}
export async function expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
orgSlug: string;
}) {
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
const document = dom?.window?.document;
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
return dom;
}
async function expectOrganizationCreationEmailToBeSentWithLinks({
page,
emails,
userEmail,
oldUsername,
newUsername,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
oldUsername: string;
newUsername: string;
orgSlug: string;
}) {
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
});
const document = dom?.window.document;
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
if (!links) {
throw new Error(`data-testid="organization-link-info doesn't have links`);
}
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
}
export async function expectEmailVerificationEmailToBeSent(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string
) {
const subject = "Cal.com: Verify your account";
return expectEmailWithSubject(page, emails, userEmail, subject);
}
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsernamePrefix = "owner";
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsernamePrefix,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsernamePrefix,
email: orgOwnerEmail,
role: "ADMIN",
});
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
const orgName = capitalize(`${orgOwnerUser.username}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
await test.step("Handover", async () => {
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
expect(onboardingUrl).toContain("settings/organizations/new");
});
});
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsername = `owner`;
const orgName = capitalize(`${orgOwnerUsername}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail: orgOwnerEmail,
orgSlug,
});
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
});
test("User can create and upgrade a org", async ({ page, users, emails }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsername,
email: targetOrgEmail,
});
await orgOwnerUser.apiLogin();
const orgName = capitalize(`${orgOwnerUsername}`);
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgOwnerUsername);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("add_a_team").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
});
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
page,
users,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const numberOfTeams = 3;
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create(
{
username: orgOwnerUsername,
email: targetOrgEmail,
},
{ hasTeam: true, numberOfTeams }
);
await orgOwnerUser.apiLogin();
await page.goto("/teams");
await test.step("Has org self serve banner", async () => {
// These values are inferred due to an existing user being signed
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
await expect(selfServeButtonLocator).toBeVisible();
await selfServeButtonLocator.click();
await page.waitForURL("/settings/organizations/new");
});
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
const slugLocator = await page.locator("input[name=slug]");
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await slugLocator.inputValue()).toBe("example");
await slugLocator.fill(`example-${stringUUID}`);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Move existing teams to org", async () => {
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
const foundCheckboxes = page.locator('input[type="checkbox"]');
const count = await foundCheckboxes.count();
for (let i = 0; i < count; i++) {
const checkbox = foundCheckboxes.nth(i);
await checkbox.click();
}
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("new-team-dialog-button").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
// eslint-disable-next-line playwright/no-skipped-test
await page.goto("/teams");
const teamListItems = await page.getByTestId("team-list-item-link").all();
// Number of teams migrated + the two created in the create teams step
expect(teamListItems.length).toBe(numberOfTeams + 2);
});
});
});
async function fillAndSubmitFirstStepAsAdmin(
page: Page,
targetOrgEmail: string,
orgName: string,
orgSlug: string
) {
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
// Since we are admin fill in this information instead of deriving it
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// Fill in seat information
await page.locator("input[name=seats]").fill("30");
await page.locator("input[name=pricePerSeat]").fill("30");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
}
+8 -6
View File
@@ -653,11 +653,12 @@ 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();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await page.waitForResponse(
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 addFilter(page, "dateRange");
await expect(
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
@@ -667,11 +668,12 @@ 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();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await page.waitForResponse(
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 addFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
+14 -8
View File
@@ -1,6 +1,7 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import type { Page } from "@playwright/test";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from "./lib/testUtils";
@@ -8,6 +9,11 @@ import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
async function goToAppsTab(page: Page, eventTypeId?: number) {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page");
}
test.describe("Payment app", () => {
test("Should be able to edit alby price, currency", async ({ page, users }) => {
const user = await users.create();
@@ -29,7 +35,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
@@ -75,7 +81,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByTestId("stripe-currency-select").click();
await page.getByTestId("select-option-usd").click();
@@ -115,7 +121,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
@@ -158,7 +164,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
@@ -185,7 +191,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
@@ -220,7 +226,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
// make sure Tracking ID is displayed
@@ -267,7 +273,7 @@ test.describe("Payment app", () => {
],
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("[data-testid='paypal-app-switch']").click();
await page.locator("[data-testid='stripe-app-switch']").isDisabled();
@@ -314,7 +320,7 @@ test.describe("Payment app", () => {
],
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.getByTestId("paypal-app-switch").click();
await page.getByTestId("paypal-price-input").fill("100");
@@ -3299,6 +3299,12 @@
"organization_onboarding_already_completed": "Organization onboarding already completed",
"organization_onboarding_already_exists": "Organization onboarding already exists",
"organization_onboarding_not_found": "Organization onboarding not found",
"no_onboarding_id_provided": "No onboarding ID provided",
"onboarding_not_found": "Onboarding not found",
"onboarding_already_complete": "Onboarding already complete",
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
"resume_onboarding": "Resume Onboarding",
"resume_onboarding_description": "Continue with your organization onboarding process",
"payment_successful": "Payment successful!",
"handover_onboarding_page_title": "Handover onboarding",
"handover_onboarding_page_description": "Handover onboarding to the user",
@@ -4,14 +4,15 @@ import { encode } from "querystring";
import type { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { getBrandingForUser } from "@calcom/features/profile/lib/getBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { prisma } from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
@@ -33,6 +34,9 @@ type UserPageProps = {
requestedSlug: string | null;
slug: string | null;
id: number | null;
brandColor: string | null;
darkBrandColor: string | null;
theme: string | null;
} | null;
allowSEOIndexing: boolean;
username: string | null;
@@ -130,15 +134,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
const branding = getBrandingForUser({ user });
const profile = {
name: user.name || user.username || "",
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
theme: branding.theme,
brandColor: branding.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
darkBrandColor:
branding.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: user.profile.organization,
+16 -2
View File
@@ -17241,6 +17241,20 @@
},
"required": ["type", "slug", "label", "required", "placeholder", "hidden", "isDefault"]
},
"BookerActiveBookingsLimitOutput_2024_06_14": {
"type": "object",
"properties": {
"maximumActiveBookings": {
"type": "number",
"description": "The maximum number of active bookings a booker can have for this event type.",
"example": 3
},
"offerReschedule": {
"type": "boolean",
"description": "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached."
}
}
},
"EventTypeOutput_2024_06_14": {
"type": "object",
"properties": {
@@ -17428,7 +17442,7 @@
"type": "object"
},
"bookerActiveBookingsLimit": {
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
},
"onlyShowFirstAvailableSlot": {
"type": "boolean"
@@ -19729,7 +19743,7 @@
"type": "object"
},
"bookerActiveBookingsLimit": {
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
},
"onlyShowFirstAvailableSlot": {
"type": "boolean"
+8 -7
View File
@@ -209,18 +209,19 @@
"group": "Atoms",
"icon": "atom",
"pages": [
"platform/atoms/cal-provider",
"platform/atoms/google-calendar-connect",
"platform/atoms/outlook-calendar-connect",
"platform/atoms/apple-calendar-connect",
"platform/atoms/availability-settings",
"platform/atoms/booker",
"platform/atoms/event-type",
"platform/atoms/cal-provider",
"platform/atoms/calendar-settings",
"platform/atoms/payment-form",
"platform/atoms/conferencing-apps",
"platform/atoms/calendar-view",
"platform/atoms/create-schedule"
"platform/atoms/conferencing-apps",
"platform/atoms/create-schedule",
"platform/atoms/event-type",
"platform/atoms/google-calendar-connect",
"platform/atoms/list-schedules",
"platform/atoms/outlook-calendar-connect",
"platform/atoms/payment-form"
]
},
{
+116
View File
@@ -0,0 +1,116 @@
---
title: "List schedules"
---
The List schedules atom displays all of a user's availability schedules and provides management actions such as delete, duplicate, and edit capabilities for each schedule.
Below code snippet can be used to render the List schedules atom
```js
import { ListSchedules } from "@calcom/atoms";
export default function ListSchedules() {
return (
<>
<ListSchedules getRedirectUrl={(scheduleId) => `/availability/${scheduleId}`} />
</>
);
}
```
For a demonstration of the List schedules atom, please refer to the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/8e020a4848d543548c519d3a7cd532c6?sid=e6ccad23-8372-402a-8fd3-c0180bc9113c"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
Below is a list of props that can be passed to the List schedules atom.
<p></p>
| Name | Required | Description |
| :------------- | :------- | :---------------------------------------------------------------------------------------------------------------- |
| getRedirectUrl | No | Callback function that determines where the user should be redirected when they click on a schedule from the atom |
## Combining list schedules atom with availability settings atom
The List schedules atom works best when combined with the Availability settings atom to provide a seamless experience. In order to combine both of atom, here are the steps we recommend:
<Steps>
<Step title="Setup list schedules atom">
Create a page for demostrating the list schedules atom.
Below code snippet can be used to setup the list schedules atom on a new page
```js
import { ListSchedules } from "@calcom/atoms";
export default function ListSchedules() {
return (
<>
<ListSchedules getRedirectUrl={(scheduleId) => `/availability/${scheduleId}`} />
</>
);
}
```
`getRedirectUrl` prop is a callback function that determines where the user should be redirected when they click on a schedule from the atom. Say if you want to display your availability settings atom on the page `/availability/:scheduleId`, you need to make sure you return the exact same url when you call the `getRedirectUrl` function.
</Step>
<Step title="Setup availability settings atom">
Create a page for demonstrating the availability settings atom. This should be a dynamic route that accepts a schedule id as a query parameter, and then the schedule id gets passed to the availability settings atom.
Below code snippet can be used to obtain the `scheduleId` and display the availability settings atom
```js
import { AvailabilitySettings } from "@calcom/atoms";
import { useRouter } from "next/router";
export default function Availability() {
const router = useRouter();
return (
<>
<AvailabilitySettings
id={router.query.scheduleId as string}
onUpdateSuccess={() => {
console.log("Updated schedule successfully");
}}
/>
</>
)
}
```
</Step>
</Steps>
-Below video shows a demonstration of how we combined the list schedules atom and availability settings atom in one of our example app to create a seamless availability management experience.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/76b65330e61e47eb95b7072ed3a42ec8?sid=9373ec6f-ba55-431e-98de-5dd3fb252a8a"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
<Info>
Link to the example app can be found
[here](https://github.com/calcom/cal.com/tree/main/packages/platform/examples/base)
</Info>
+1 -1
View File
@@ -1,5 +1,5 @@
import { HttpError } from "@calcom/lib/http-error";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
@@ -1,6 +1,6 @@
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
@@ -1,7 +1,7 @@
import type z from "zod";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
@@ -4,8 +4,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata";
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { SMSLockState, RRTimestampBasis } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
@@ -28,7 +28,7 @@ vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository",
}));
// Mock DelegationCredentialRepository
vi.mock("@calcom/lib/server/repository/delegationCredential", () => ({
vi.mock("@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository", () => ({
DelegationCredentialRepository: {
findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey: vi.fn(),
findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey: vi.fn(),
+3 -3
View File
@@ -10,9 +10,9 @@ import {
} from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { SelectedCalendar } from "@calcom/prisma/client";
@@ -1,7 +1,7 @@
import { Dub } from "dub-package";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -14,7 +14,7 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
+10 -9
View File
@@ -7,8 +7,8 @@ import type {
SimplePublicObjectInput,
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
import { getLocation } from "@calcom/lib/CalEventParser";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -21,10 +21,6 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { HubspotToken } from "../api/callback";
interface CustomPublicObjectInput extends SimplePublicObjectInput {
id?: string;
}
export default class HubspotCalendarService implements CRM {
private url = "";
private integrationName = "";
@@ -117,8 +113,14 @@ export default class HubspotCalendarService implements CRM {
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
};
private hubspotDeleteMeeting = async (uid: string) => {
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
private hubspotCancelMeeting = async (uid: string) => {
const simplePublicObjectInput: SimplePublicObjectInput = {
properties: {
hs_meeting_outcome: "CANCELED",
},
};
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
};
private hubspotAuth = async (credential: CredentialPayload) => {
@@ -204,7 +206,6 @@ export default class HubspotCalendarService implements CRM {
return await this.handleMeetingCreation(event, contacts);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
const auth = await this.auth;
await auth.getToken();
@@ -214,7 +215,7 @@ export default class HubspotCalendarService implements CRM {
async deleteEvent(uid: string): Promise<void> {
const auth = await this.auth;
await auth.getToken();
return await this.hubspotDeleteMeeting(uid);
await this.hubspotCancelMeeting(uid);
}
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
@@ -1,8 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import {
entityPrismaWhereClause,
canEditEntity,
} from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import { sanitizeValue } from "@calcom/lib/csvUtils";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import prisma from "@calcom/prisma";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
@@ -3,9 +3,9 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import {
WebhookTriggerEvents,
WorkflowTriggerEvents,
@@ -37,7 +37,7 @@ vi.mock("@calcom/features/tasker", () => {
});
// Mock workflow dependencies
vi.mock("@calcom/lib/server/service/workflows", () => ({
vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService", () => ({
WorkflowService: {
getAllWorkflowsFromRoutingForm: vi.fn(() => Promise.resolve([])),
scheduleFormWorkflows: vi.fn(() => Promise.resolve()),
@@ -6,7 +6,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -1,6 +1,6 @@
import type { z } from "zod";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { canCreateEntity, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import { canCreateEntity, canEditEntity } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import prisma from "@calcom/prisma";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import logger from "@calcom/lib/logger";
import { routingFormIncompleteBookingDataSchema } from "../../zod";
@@ -1,4 +1,4 @@
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { AttributeRoutingConfig } from "../../routing-forms/types/types";
import SalesforceCRMService from "./CrmService";
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import {
@@ -18,7 +18,7 @@ import { validateAuthentication, buildReturnUrl } from "../portal";
// Mock dependencies
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/lib/server/repository/team");
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
vi.mock("../../lib/customer");
vi.mock("../../lib/server");
vi.mock("../../lib/subscriptions");
@@ -4,7 +4,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import stripe from "../../server";
@@ -1,4 +1,4 @@
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import type { BillingPortalService } from "../base/BillingPortalService";
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
@@ -32,7 +32,7 @@ import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
@@ -6,6 +6,7 @@ import { getLocaleFromRequest } from "@calcom/features/auth/lib/getLocaleFromReq
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
@@ -15,7 +16,6 @@ import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import type { CustomNextApiHandler } from "@calcom/lib/server/username";
import { usernameHandler } from "@calcom/lib/server/username";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { CreationSource } from "@calcom/prisma/enums";
import { IdentityProvider } from "@calcom/prisma/enums";
@@ -3,12 +3,12 @@ import { NextResponse } from "next/server";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
+1 -1
View File
@@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/features/auth/signup/utils/validateUsername";
import { HttpError } from "@calcom/lib/http-error";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
@@ -1,4 +1,4 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { IntervalTree, ContainmentSearchAlgorithm, createIntervalNodes } from "@calcom/lib/intervalTree";
/**
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import dayjs from "@calcom/dayjs";
import type { DateRange } from "@calcom/lib/date-ranges";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { mergeOverlappingDateRanges } from "./mergeOverlappingDateRanges";
@@ -1,4 +1,4 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
export function mergeOverlappingDateRanges(dateRanges: DateRange[]) {
dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
@@ -1,10 +1,10 @@
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { intersect } from "@calcom/features/schedules/lib/date-ranges";
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
import type { DateRange } from "@calcom/lib/date-ranges";
import { intersect } from "@calcom/lib/date-ranges";
import { SchedulingType } from "@calcom/prisma/enums";
import { filterRedundantDateRanges } from "./getAggregatedAvailability/date-range-utils/filterRedundantDateRanges";
import { mergeOverlappingDateRanges } from "./getAggregatedAvailability/date-range-utils/mergeOverlappingDateRanges";
import { filterRedundantDateRanges } from "./date-range-utils/filterRedundantDateRanges";
import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges";
function uniqueAndSortedDateRanges(ranges: DateRange[]): DateRange[] {
const seen = new Set<string>();
@@ -12,9 +12,9 @@ import {
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-ranges";
import { getWorkingHours } from "@calcom/lib/availability";
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { stringToDayjsZod } from "@calcom/lib/dayjs";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
@@ -641,9 +641,9 @@ export class UserAvailabilityService {
// you can obtain that from user availability defined outside of here
fromUser: { id: user.id, displayName: user.name },
// optional chaining destructuring toUser
toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: !!reason ? reason.reason : null,
emoji: !!reason ? reason.emoji : null,
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: reason ? reason.reason : null,
emoji: reason ? reason.emoji : null,
};
}
@@ -8,7 +8,7 @@ import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocati
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
import { SystemField } from "@calcom/lib/SystemField";
import { SystemField } from "@calcom/lib/bookings/SystemField";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -24,7 +24,7 @@ import {
getPiiFreeCalendarEvent,
} from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { prisma } from "@calcom/prisma";
import type { DestinationCalendar, BookingReference } from "@calcom/prisma/client";
import { createdEventSchema } from "@calcom/prisma/zod-utils";
@@ -1,8 +1,7 @@
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import logger from "../logger";
type BookingEventType = {
slug: string;
team: {
@@ -3,7 +3,7 @@ import type { z } from "zod";
import {
SMS_REMINDER_NUMBER_FIELD,
CAL_AI_AGENT_PHONE_NUMBER_FIELD,
} from "@calcom/lib/SystemField";
} from "@calcom/lib/bookings/SystemField";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
@@ -2,7 +2,8 @@ import { isValidPhoneNumber } from "libphonenumber-js";
import z from "zod";
import type { ALL_VIEWS } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema, fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
@@ -1,6 +1,6 @@
import type z from "zod";
import { SystemField } from "@calcom/lib/SystemField";
import { SystemField } from "@calcom/lib/bookings/SystemField";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
@@ -3,7 +3,7 @@ import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
import { mergeOverlappingRanges } from "@calcom/lib/date-ranges";
import { mergeOverlappingRanges } from "@calcom/features/schedules/lib/date-ranges";
import logger from "@calcom/lib/logger";
import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -6,7 +6,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Prisma } from "@calcom/prisma/client";
import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
@@ -18,7 +18,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
@@ -70,8 +70,8 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaAttributeRepository as AttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import type { HostRepository } from "@calcom/lib/server/repository/host";
import type { PrismaOOORepository as OooRepository } from "@calcom/lib/server/repository/ooo";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import type { DestinationCalendar, Prisma, User, AssignmentReasonEnum } from "@calcom/prisma/client";
@@ -3,7 +3,7 @@ import type { Logger } from "tslog";
import dayjs from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import { buildDateRanges } from "@calcom/lib/date-ranges";
import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
import { ErrorCode } from "@calcom/lib/errorCodes";
@@ -4,7 +4,7 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -6,9 +6,9 @@ import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { handleNoShowFee } from "./handleNoShowFee";
@@ -30,7 +30,7 @@ vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/lib/server/repository/credential", () => ({
vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => ({
CredentialRepository: {
findPaymentCredentialByAppIdAndUserIdOrTeamId: vi.fn(),
findPaymentCredentialByAppIdAndTeamId: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => (
},
}));
vi.mock("@calcom/lib/server/repository/team", () => ({
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
TeamRepository: vi.fn().mockImplementation(() => ({
findParentOrganizationByTeamId: vi.fn(),
})),
@@ -7,9 +7,9 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -1,7 +1,7 @@
import dayjs from "@calcom/dayjs";
import { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
import { subtract } from "@calcom/lib/date-ranges";
import { subtract } from "@calcom/features/schedules/lib/date-ranges";
import { stringToDayjs } from "@calcom/lib/dayjs";
import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
@@ -3,6 +3,7 @@ import { v4 as uuid } from "uuid";
import { CalendarAuth } from "@calcom/app-store/googlecalendar/lib/CalendarAuth";
import dayjs from "@calcom/dayjs";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
@@ -117,12 +118,12 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP
};
if (!syncToken) {
const now = dayjs();
const now = dayjs().startOf("day");
// first sync or unsync (3 months)
const threeMonths = now.add(3, "month");
const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day");
const timeMinISO = now.toISOString();
const timeMaxISO = threeMonths.toISOString();
const timeMaxISO = monthsAhead.toISOString();
params.timeMin = timeMinISO;
params.timeMax = timeMaxISO;
} else {
@@ -151,8 +152,21 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP
}
private parseEvents(events: calendar_v3.Schema$Event[]): CalendarSubscriptionEventItem[] {
const now = dayjs().startOf("day");
const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day");
function filterEventsWithoutId(event: calendar_v3.Schema$Event) {
return typeof event.id === "string" && !!event.id;
}
function filterEventsByDateRange(event: calendar_v3.Schema$Event) {
const start = dayjs(event.start?.dateTime || event.start?.date);
return !start.isBefore(now) && !start.isAfter(monthsAhead);
}
return events
.filter((event) => typeof event.id === "string" && !!event.id)
.filter(filterEventsWithoutId)
.filter(filterEventsByDateRange)
.map((event) => {
// empty or opaque is busy
const busy = !event.transparency || event.transparency === "opaque";
@@ -2,11 +2,25 @@ import "../__mocks__/CalendarAuth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter";
const addMonthsFromNow = (months: number) => {
const date = dayjs();
return date.add(months, "months").startOf("day");
};
const today = dayjs().startOf("day");
const oneWeekFromNow = today.add(7, "days");
const eventEndTime = oneWeekFromNow.add(1, "hours");
const channelExpirationDate = new Date(Date.now() + 86400000);
vi.mock("uuid", () => ({
v4: vi.fn().mockReturnValue("test-uuid"),
}));
@@ -30,16 +44,16 @@ const mockSelectedCalendar: SelectedCalendar = {
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
createdAt: today.toDate(),
updatedAt: today.toDate(),
channelId: "test-channel-id",
channelKind: "web_hook",
channelResourceId: "test-resource-id",
channelResourceUri: "test-resource-uri",
channelExpiration: new Date(Date.now() + 86400000),
syncSubscribedAt: new Date(),
channelExpiration: channelExpirationDate,
syncSubscribedAt: today.toDate(),
syncToken: "test-sync-token",
syncedAt: new Date(),
syncedAt: today.toDate(),
syncErrorAt: null,
syncErrorCount: 0,
};
@@ -176,7 +190,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: "1640995200000",
expiration: String(channelExpirationDate.getTime()),
},
};
@@ -202,17 +216,18 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: new Date(1640995200000),
expiration: channelExpirationDate,
});
});
test("should handle expiration as ISO string", async () => {
const mockExpirationDate = new Date(Date.now() + 10000000);
const mockWatchResponse = {
data: {
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: "2023-12-01T10:00:00Z",
expiration: mockExpirationDate.toISOString(),
},
};
@@ -220,7 +235,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
const result = await adapter.subscribe(mockSelectedCalendar, mockCredential);
expect(result.expiration).toEqual(new Date("2023-12-01T10:00:00Z"));
expect(result.expiration).toEqual(mockExpirationDate);
});
test("should handle missing expiration", async () => {
@@ -265,6 +280,19 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
});
describe("fetchEvents", () => {
const commonEventData = {
iCalUID: "event-1@cal.com",
summary: "Test Event",
description: "Test Description",
location: "Test Location",
status: "confirmed",
transparency: "opaque",
kind: "calendar#event",
etag: "test-etag",
created: today.toISOString(),
updated: today.toISOString(),
};
test("should fetch events with sync token", async () => {
const mockEventsResponse = {
data: {
@@ -272,23 +300,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
summary: "Test Event",
description: "Test Description",
location: "Test Location",
...commonEventData,
start: {
dateTime: "2023-12-01T10:00:00Z",
dateTime: oneWeekFromNow.toISOString(),
timeZone: "UTC",
},
end: {
dateTime: "2023-12-01T11:00:00Z",
dateTime: eventEndTime.toISOString(),
},
status: "confirmed",
transparency: "opaque",
kind: "calendar#event",
etag: "test-etag",
created: "2023-12-01T09:00:00Z",
updated: "2023-12-01T09:30:00Z",
},
],
},
@@ -312,8 +331,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-1",
iCalUID: "event-1@cal.com",
start: new Date("2023-12-01T10:00:00Z"),
end: new Date("2023-12-01T11:00:00Z"),
start: oneWeekFromNow.toDate(),
end: eventEndTime.toDate(),
busy: true,
summary: "Test Event",
description: "Test Description",
@@ -325,14 +344,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
timeZone: "UTC",
recurringEventId: null,
originalStartDate: null,
createdAt: new Date("2023-12-01T09:00:00Z"),
updatedAt: new Date("2023-12-01T09:30:00Z"),
createdAt: today.toDate(),
updatedAt: today.toDate(),
},
],
});
});
test("should fetch events without sync token (initial sync)", async () => {
test("should fetch events without sync token (initial sync) with a 3-month range", async () => {
const calendarWithoutSyncToken = {
...mockSelectedCalendar,
syncToken: null,
@@ -349,34 +368,37 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
const result = await adapter.fetchEvents(calendarWithoutSyncToken, mockCredential);
const expectedTimeMin = today.startOf("day").toISOString();
const expectedTimeMax = addMonthsFromNow(3).endOf("day").toISOString();
expect(mockClient.events.list).toHaveBeenCalledWith({
calendarId: "test@example.com",
pageToken: undefined,
singleEvents: true,
timeMin: expect.any(String),
timeMax: expect.any(String),
timeMin: expectedTimeMin,
timeMax: expectedTimeMax,
});
expect(result.syncToken).toBe("initial-sync-token");
});
test("should handle all-day events", async () => {
const allDayStart = oneWeekFromNow.startOf("day");
const allDayEnd = oneWeekFromNow.endOf("day");
const mockEventsResponse = {
data: {
nextSyncToken: "new-sync-token",
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
summary: "All Day Event",
...commonEventData,
start: {
date: "2023-12-01",
date: allDayStart.toISOString().split("T")[0],
},
end: {
date: "2023-12-02",
date: allDayEnd.toISOString().split("T")[0],
},
status: "confirmed",
transparency: "opaque",
},
],
},
@@ -387,8 +409,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
expect(result.items[0].isAllDay).toBe(true);
expect(result.items[0].start).toEqual(new Date("2023-12-01"));
expect(result.items[0].end).toEqual(new Date("2023-12-02"));
expect(result.items[0].start).toEqual(new Date(allDayStart.toISOString().split("T")[0]));
expect(result.items[0].end).toEqual(new Date(allDayEnd.toISOString().split("T")[0]));
});
test("should handle free events (transparent)", async () => {
@@ -398,15 +420,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
...commonEventData,
summary: "Free Event",
start: {
dateTime: "2023-12-01T10:00:00Z",
dateTime: oneWeekFromNow.toISOString(),
},
end: {
dateTime: "2023-12-01T11:00:00Z",
dateTime: eventEndTime.toISOString(),
},
status: "confirmed",
transparency: "transparent",
},
],
@@ -421,6 +442,9 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
});
test("should handle pagination", async () => {
const date2 = new Date(oneWeekFromNow.toDate());
date2.setDate(date2.getDate() + 1);
const mockEventsResponse1 = {
data: {
nextPageToken: "page-2",
@@ -428,8 +452,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-1",
summary: "Event 1",
start: { dateTime: "2023-12-01T10:00:00Z" },
end: { dateTime: "2023-12-01T11:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
],
},
@@ -442,8 +466,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-2",
summary: "Event 2",
start: { dateTime: "2023-12-01T12:00:00Z" },
end: { dateTime: "2023-12-01T13:00:00Z" },
start: { dateTime: date2.toISOString() },
end: { dateTime: new Date(date2.getTime() + 3600000).toISOString() },
},
],
},
@@ -468,19 +492,19 @@ describe("GoogleCalendarSubscriptionAdapter", () => {
{
id: "event-1",
summary: "Valid Event",
start: { dateTime: "2023-12-01T10:00:00Z" },
end: { dateTime: "2023-12-01T11:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
{
summary: "Invalid Event",
start: { dateTime: "2023-12-01T12:00:00Z" },
end: { dateTime: "2023-12-01T13:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
{
id: "",
summary: "Empty ID Event",
start: { dateTime: "2023-12-01T14:00:00Z" },
end: { dateTime: "2023-12-01T15:00:00Z" },
start: { dateTime: oneWeekFromNow.toISOString() },
end: { dateTime: eventEndTime.toISOString() },
},
],
},
@@ -83,9 +83,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito
async deleteStale() {
return this.prismaClient.calendarCacheEvent.deleteMany({
where: {
end: {
lte: new Date(),
},
end: { lte: new Date() },
},
});
}
@@ -9,6 +9,7 @@ const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] });
* Service to handle calendar cache
*/
export class CalendarCacheEventService {
static MONTHS_AHEAD: number = 3;
constructor(
private deps: {
calendarCacheEventRepository: ICalendarCacheEventRepository;
@@ -239,9 +239,7 @@ describe("CalendarCacheEventRepository", () => {
expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({
where: {
end: {
lte: expect.any(Date),
},
end: { lte: expect.any(Date) },
},
});
});

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